한번도 사용해본적 없는 기술이라 개념부터 차근차근 찾아가며 적용해보고 있다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/v1/users/join","/api/v1/users/login").permitAll()
.antMatchers("/api/boards/**").hasRole("USER")
.anyRequest().authenticated();
return http.build();
}
}
우선 Spring security의 수많은 필터 가운데 내가 커스텀한 필터를 끼우기 위한 코드이다. 어노테이션 @EnableWebSecurity
는 스프링 시큐리티 필터를 스프링 필터체인에 등록하는 의미를 가지고 있다.
스프링부트 2.7이전 버전에서는 Security 설정을 WebSecurityConfigurerAdapter 클래스를 상속받아 구현했지만, 이후 버전부터는 해당 방식이 Deprecated됐다.
코드의 내용은 위에서부터 살표보자.
httpBasic().disable()
: 현재 구현하는 프로젝트는 rest api이므로 basic auth를 사용하지 않겠다는 의미csrf().disable()
: 저번 시간에 공부한 CORS에서 나온 개념인 csrf이다. cross site resource forgery 보안을 사용하지 않겠다는 설정이다.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
: 세션 정책을 stateless로 사용하겠다는 의미인데 이후 JWT를 이용하려고 설정했다.antMatchers()
: URI를 안에 넣어준다.permitAll()
: 인증 절차 없이 접속을 허용해줌.hasRole()
: 권한에 제한을 두는 것. 코드에서는 user레벨이라면 통과hasAnyRole(String ... role)
을 사용하면 권한 여러개(제한 없음)를 설정할 수 있다.anyRequest().authenticated()
: 남은 모든 요청은 인증을 하겠다는 설정임.이것 외에도 다양한 기능을 추가할 수 있다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Member extends BaseEntity implements UserDetails {
@Id @GeneratedValue
@Column(name = "user_id")
private Long id;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String password;
@ElementCollection(fetch = FetchType.EAGER)
private List<String> roles = new ArrayList<>();
private Member(String email, String password) {
this.email = email;
this.password = password;
roles.add("ROLE_USER");
}
public static Member createUser(String email, String password) {
return new Member(email, password);
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
spring security에서 사용하는 UserDetails 인터페이스를 상속받아 구현한 도메인 Member.
역할을 여러개 가질 수 있도록 리스트 형식으로 필드에 추가했다. ORM을 사용하는데 컬렉션을 DB에 저장할 수는 없으므로 ElementCollection
을 사용했다. 실제 사용하기에는 안좋은 점이 많아 실무에서 사용을 지양하지만 프로젝트의 규모를 고려하여 사용했다.
여기서 굉장히 고민했던 것이 있는데 바로 getUsername()
메서드다. 이 메서드가 정확히 어디서 사용되는지 하루종일 찾아보고 어떻게 설정해야 하는지 고민했다. 아직 현재 진행형으로 해결하지 못한 문제지만 지금까지 알아낸 내용은 다음과 같다. 우선 리턴값으로 DB의 PK처럼 중복하지 않는 값을 사용해야한다. 그리고 불변해야만 한다. 그래서 사실 현재 엔티티의 PK인 Long id를 넘겨주려고 id.toString()
을 시도했다. 하지만 로그인 과정에서 DB에만 저장돼 있는 id값을 알고 넘겨줄 리는 없기 때문에 그냥 email을 사용했다. email은 SSO 서비스를 사용하면 중복하는 문제점이 있다는 글을 봤기 때문에 선뜻 사용하지는 못했다. 하지만 현재 프로젝트에서는 이메일의 중복 가입을 막고 있기 때문에 우선은 email을 사용했다.
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
private final MemberRepository memberRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
public String join(Member member) {
//email 중복 check
memberRepository.findByEmail(member.getEmail())
.ifPresent(u -> {
throw new EmailDupException(member.getEmail() + "이 이미 존재합니다.");
});
//저장
memberRepository.save(member);
return "SUCCESS";
}
public String login(String email, String password) {
// 1. Login EMAIL/PW 를 기반으로 Authentication 객체 생성
// 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password);
// 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
// authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
// TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);
//
// return tokenInfo;
return "ok";
}
}
지난 프로젝트에서 login서비스를 구현했다. 아직 JWT를 구현하지 못했지만 JWT를 구현한다면 주석 3번의 로직을 거치고 리턴값을 JWT토큰으로 설정하면 될 것 같다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) {
return memberRepository.findByEmail(email)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException("해당 유저를 찾을 수 없습니다."));
}
private UserDetails createUserDetails(Member member) {
return User.builder()
.username(member.getUsername())
.password(member.getPassword())
.roles(member.getRoles().toArray(new String[0]))
.build();
}
}
위 주석 2번에서 언급한 loadUserByUsername
메서드가 바로 여기서 구현된다. UserDetailsService
인터페이스를 상속받아 구현한 내용으로 email을 받아 검증하는 과정이다.
아직 Spring security의 방대한 개념을 이해하기에 시간이 많이 필요한 것 같고 JWT를 구현하는 길도 멀었다. 따라서 개념을 완벽히 이해하기 전에 예제 블로그를 통해 따라하고 그 결과를 확인해가며 공부하는 방향을 잡았다.
참고한 블로그 글입니다.
https://gksdudrb922.tistory.com/217#MemberController