Spring을 사용하면서 로그인등의 기능이 필요한 경우가 아주 많다.
정말 처음 배울 때 처럼, Plain text로서 PW를 저장한다던가 하는건 불법이고 당연히 배포할 프로젝트에서 보안적으로 절대 사용해선 안된다.
따라서, 우리가 편하게 기능들을 사용 할 수 있도록 도와주는 Spring Security를 사용해보자.
Spring Security 구조
Spring Security는 진입 장벽을 가진 Reference doc을 가지고 있다.
https://docs.spring.io/spring-security/reference/index.html
이 글에서,같이 Refernce doc을 읽으며 세션 기반 로그인을 구현해보자.
@Entity @Getter
public class Member {
@Id @GeneratedValue
private Long id;
private String username;
private String nickname;
private String password;
private LocalDate registeredAt;
@Enumerated(EnumType.STRING)
private Role role;
}
먼저, User에 대한 Entity를 선언하자. Role의 경우엔 필수라고 할 수는 없지만, 그래도 Reference 문서에서 많이 활용하는 것 같기에 추가했다.
@Getter
public enum Role {
ROLE_ADMIN("admin"), ROLE_USER("user");
private final String description;
Role(String description) {
this.description = description;
}
}
Role은 Enumerate로 선언해주자.
출처 : https://mangkyu.tistory.com/
Spring Security는 코드를 작성 하기 전, 구조를 잘 파악해둬야 한다. 코드 짜는 시간보다는, 구조 파악하는 시간이 더 길다..
- 사용자가 로그인 요청(Request)를 보낸다.
- AuthenticationFilter가 이를 캐치한다. 이후 UsernamePasswordAuthenticationToken 객체를 생성한다. 이 토큰은 JWT같은 토큰이 아니다!
- AuthenticatinManager는 인터페이스기에, 이를 구현한 ProviderManager에게 방금 생성한 객체를 넘긴다.
- ProviderManager는 AuthenticationProvider (여러 개 일 수 있음)을 통해 인증을 요청한다
- Provider(s)는 우리 DB에서 사용자 정보를 가져올 Service인 UserDetailsService에 정보를 넘긴다.
- Service를 통해, UserDetails 객체 (return받은 사용자 정보)를 얻는다.
- AuthenticationProvider(s)는 객체의 정보를 가지고 올바른지 인증한다.
- 성공시, Authentication 객체를 만들어 반환한다.
- 필터로 해당 객체를 넘긴다.
- 이 Authentication 객체를, SecurityContext에 넣는다. 이는 곧 세션이다.
대략적인 흐름만 생각하면서, 코드를 작성해 공부해보자.
https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/index.html
위 문서를 기반으로 코드를 작성해보자.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception{
httpSecurity
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/test").authenticated()
.requestMatchers("/admin").hasRole("ADMIN")
.anyRequest().permitAll())
.formLogin(form -> form
.loginPage("/login")
.permitAll());
return httpSecurity.build();
}
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
먼저 Config를 하나 만들어주자.
"/test"에 대해선 인증이 필요하고 (로그인 필요)
"/admin"에 대해선 ROLE_ADMIN이,
그 외 페이지에 대해선 누구나 접근 가능하게 설정했다.
이후, "/login"은 로그인 페이지로서 지정했다 로그인 페이지 또한 당연하게도 permitAll()로 누구나 접근 가능하게 해준다.
나는 BCryptEncoder 를 비밀번호 Encoder로서 사용할것이기에, Bean으로 등록해줬다.
먼저 ,회원가입부터 구현하자. 회원가입은 Spring Security를 거치지 않고 자체 구현하면 된다. Spring Security는 인증과 인가, 권한을 관리하기에 회원 가입과 큰 연관은 없다.
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
}
먼저 리포지토리를 만들어주고
@SpringBootTest
@Transactional
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Autowired
private BCryptPasswordEncoder encoder;
@Test
@Commit
void register(){
Member member = Member.builder()
.username("testid")
.password(encoder.encode("password"))
.nickname("testNick")
.registeredAt(LocalDate.now())
.role(Role.ROLE_ADMIN)
.build();
Member saved = memberRepository.save(member);
Assertions.assertThat(saved).isEqualTo(member);
}
}
테스트 코드를 통해 DB에 테스트 데이터를 하나 넣자. @Commit을 붙이면 @Transactional이 붙어있어도 롤백되지 않는다.
아까 빈으로 등록한 encoder도 불러와서, PW는 Encode해서 넣어준다!
행복
데이터도 잘 들어갔다. 이제 다시 로그인을 구현해보자.
간단하게만 구현 할 경우, 반드시 추가로 설정해야 할 것은 UserDetailsService와 Users다.
UserDetailsService에는 우리가 디비에서 Username을 기반으로 정보 (id, pw, role..)를 가져오기 위한 인터페이스고
Users는 Security에 있는 클래스를 우리 엔티티인 Member로서 사용해야 한다.
@Getter @Setter
public class CustomUser extends User {
private Member member;
public CustomUser(Member member) {
super(member.getUsername(), member.getPassword(), AuthorityUtils.createAuthorityList(member.getRole().toString()));
this.member = member;
}
}
위와 같이 만들어준다. 어려운 코드는 아니다. 해당 커스텀 유저는 Bean으로서 등록하지 않아도 된다 (주입받는 클래스는 존재하지 않는다.)
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByUsername(String userId);
}
그리고 리포지토리에 findByUsername을 만들어주자.
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<Member> member = memberRepository.findByUsername(username);
return member.map(CustomUser::new).orElseThrow(() -> new UsernameNotFoundException(username));
}
}
이후 UserDetailsService를 구현하는 클래스를 만들자. UserDetailsService는 인터페이스다!
loadUserByUsername을 Override하면 된다.
여기서 우리 디비에서 아이디를 기반으로 엔티티를 찾아오고, 위에서 만든 커스텀 유저에 매핑해서 던져버리면 된다. 람다식 최고
이제, POST로 (form 데이터로!!)username, password를 작성해 /login에 던져버리면 처리가 된다.
하지만 난 이 과정에서 로그인이 되지 않는 문제를 발견했고, 디버깅을 시작했다.
시큐리티 config에서, failure handler를 등록한다.
@Slf4j
public class LoginFailHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
log.info("fail handler");
log.info(exception.getMessage());
super.onAuthenticationFailure(request, response, exception);
}
}
핸들러 클래스를 만들어서, 단순하게 로그만 찍어봤다.
로그인 실패랑 같은뜻인데, 정말 많이 찾아보니까
username, password는 파라미터 이름이 엔티티에도 동일해야한다. 수정된 상태로 업로드 하지만, 이 글을 처음 쓸 때는 username이 아니라 userId로 했었다.
만약 엔티티 이름을 바꿀수 없는 사람들은
Config에서 parameter 이름을 바꿔주자. 이거 몰라서 거의 반나절이 날아갔다.