Spring Security - 사용법

대영·2024년 3월 1일
2

Spring

목록 보기
12/15

🙏내용에 대한 피드백은 언제나 환영입니다!!🙏

앞 글에서 Spring Security의 구조에 대해서 알아보았다.
이제 이것을 사용해보자.
여기서는 OAuth2.0 로그인도 같이 적용해보겠다.

Security를 사용하기 위해 build.gradle에 아래에 적힌 걸 추가하자.

implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

📌Security Filter 등록

우선 Spring Security를 Filter에 등록해야 한다.

  1. Spring Boot에 등록
    • @Configuration 어노테이션으로 Spring Boot에 등록한다.
  2. 스프링 Filter에 등록
    • @EnableWebSecurity를 이용하여 Spring Filter Chain에 등록한다.
  3. SecurityFilterChain를 Bean 등록
    • 필요한 기능을 넣어서 @Bean 등록한다.

Spring Security 5.7.x 버전부터는 WebSecurityConfigurerAdapter를 상속 받지 않고, 기능에 대해서는 람다식으로 구성해야 한다.
그 이유는 공식문서에 적혀있다.
The previous way it was not clear what object was getting configured without knowing what the return type was. The deeper the nesting the more confusing it became. Even experienced users would think that their configuration was doing one thing when in fact, it was doing something else.

  • 이전 방식에서는 반환 유형이 무엇인지 알지 못한 채 어떤 객체가 구성되고 있는지 명확하지 않았습니다. 중첩이 깊어질수록 혼란스러워졌습니다. 숙련된 사용자라도 자신의 구성이 실제로는 다른 작업을 수행하고 있으면서도 특정 작업을 수행하고 있다고 생각할 것입니다.

아래는 위의 조건에 맞게 구성한 것이며, 메타코딩 유튜브를 통해서 공부하며 작성한 코드이다.

@Configuration
@EnableWebSecurity  // 스프링 시큐리티 필터(SecurityConfig)가 스프링 필터체인에 등록이 된다.
@RequiredArgsConstructor
public class SecurityConfig {

    private final PrincipalOauth2UserService principalOauth2UserService;    // OAuth2.0 로그인.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.
                csrf(csrfConfig ->
                        csrfConfig.disable())       // CSRF 공격 방지를 위한 기능을 비활성화
                .authorizeHttpRequests(authorize ->         //  HTTP 요청에 대한 인가 규칙을 설정
                        authorize                   // 아래와 같이 전체적으로 권한을 설정.
                                .requestMatchers("/user/**").authenticated() // 인증된(authenticated) 사용자에게만 허용
                                .requestMatchers("/manager/**").hasAnyRole("ADMIN", "MANAGER") //"ADMIN" 또는 "MANAGER" 역할을 가진 사용자에게만 허용
                                .requestMatchers("/admin/**").hasAnyRole("ADMIN") // "ADMIN" 역할을 가진 사용자에게만 허용
                                .anyRequest().permitAll())   // 나머지 요청에 대해서는 모든 권한을 허용
                .formLogin(formLogin ->            // Spring Security 구성을 정의하는 데 사용되는 메서드
                        formLogin
                                .loginPage("/loginForm")      // 로그인 페이지
                                .loginProcessingUrl("/login") // /login 주소가 호출되면 시큐리티가 낚아채서 대신 !!로그인 진행.
                                .defaultSuccessUrl("/"))   // 로그인하면 이 공간으로 감. (여기서는 "/"으로)
                .logout(logout ->
                        logout
                                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                                .logoutSuccessUrl("/loginForm"))
                .oauth2Login(oauth2 ->      //  OAuth 2.0 로그인을 구성하는 메서드.  구글 로그인이 완료된 뒤의 후처리가 필요함.
                        oauth2
                                .loginPage("/loginForm")
                                .userInfoEndpoint(userInfoEndpoint ->   //  OAuth 2.0에서 사용자 정보 엔드포인트를 구성하는 메서드
                                        userInfoEndpoint
                                                .userService(principalOauth2UserService)));  // 사용자 정보를 가져오는 서비스를 설정.
                                                                                    // principalOauth2UserService 여기에 후처리 등록.
        return http.build();
    }
}

📌formLogin - UserDetails 구성

객체를 받기 위한 UserDetails를 구성해보겠다.

아래 코드는 구현해야 할 UserDetails Interface이다.

public interface UserDetails extends Serializable {

	Collection<? extends GrantedAuthority> getAuthorities();	// 권한 들고오기

	String getPassword();		// 비밀번호 들고오기.
    
	String getUsername();		// 아이디 들고오기.

	boolean isAccountNonExpired();	    // 계정이 만료 되었는지

	boolean isAccountNonLocked();	   // 계정이 잠겼는지

	boolean isCredentialsNonExpired();	  // 계정이 오래 되었는지

	boolean isEnabled();	        // 계정이 활성화 되었는지

}

이 코드에 맞게 구성해보겠다.

@Data
public class PrincipalDetails implements UserDetails {

    private final User user;

    public PrincipalDetails(User user) {
        this.user = user;
    }
    
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorize = new ArrayList<>();
        authorize.add(() -> String.valueOf(user.getRole()));
        // Role은 Enum으로 구성하여 String으로 바꿔주어야 함.
        return null;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.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;
    }
}

📌formLogin - UserDetailsService 등록

이제 사용자의 정보를 데이터베이스에서 찾아 가져오는 과정을 보겠다.

아래 코드는 구현해야 할 UserDetailsService Interface이다.

public interface UserDetailsService {

	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

}

로그인 인증 과정에서 loadUserByUsername이 호출 되는 것이다.

그렇다면, 코드에 맞게 작성해보겠다.

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {
    
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if(username == null || username.equals("")) {
            throw new UsernameNotFoundException(username);
        }
        
        User userEntity = userRepository.findByUsername(username);
        
        if(userEntity == null) {
            throw new UsernameNotFoundException("사용자를 찾을수 없습니다.");
        }
        return new PrincipalDetails(userEntity);
    }
}

여기서 가져온 객체를 return new PrincipalDetails(userEntity);로 넘긴다.

위와 같이 구성하면 사용자의 정보를 찾아 보내어 요청 받은(로그인) 정보와 비교하여 인증을 하는 것이다.

이제, OAuth2.0을 보겠다.

📌OAuth2.0 - OAuth2User 구성

OAuth2.0을 구성하기 위해 OAuth2User interface를 구성해야 한다.
아래의 코드에서 볼 수 있듯이 OAuth2User는 OAuth2AuthenticatedPrincipal을 상속받는다.

public interface OAuth2User extends OAuth2AuthenticatedPrincipal {
}

OAuth2AuthenticatedPrincipal을 보자면

public interface OAuth2AuthenticatedPrincipal extends AuthenticatedPrincipal {

	@Nullable
	@SuppressWarnings("unchecked")
	default <A> A getAttribute(String name) {
		return (A) getAttributes().get(name);
	}

	Map<String, Object> getAttributes();

	Collection<? extends GrantedAuthority> getAuthorities();

}

복잡하지만, AuthenticatedPrincipal을 상속받기에 이것도 본다면

public interface AuthenticatedPrincipal {

	String getName();

}

즉, 구현해야 할 것은
인증된 사용자의 속성을 가져 오는 Map<String, Object> getAttributes(),
사용자의 권한을 가져오는 Collection<? extends GrantedAuthority> getAuthorities(),
인증된 사용자의 식별자를 가져오기 위한 메서드인 String getName() 이다.

그래서, 위에 구성한 PrincipalDetails에 OAuth2User또한 구현하자.

public class PrincipalDetails implements UserDetails, OAuth2User  {
	
	private final User user;
    private Map<String, Object> attributes;

    public PrincipalDetails(User user) {
        this.user = user;
    }

    public PrincipalDetails(User user, Map<String, Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

	@Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorize = new ArrayList<>();
        authorize.add(() -> String.valueOf(user.getRole()));
        return null;
    }

	...

	// 권한은 이미 추가되어 있음.

	@Override
    public String getName() {
        return String.valueOf(user.getEmail());
    }
}

📌OAuth2.0 - DefaultOAuth2UserService 등록

OAuth2.0에서도 데이터베이스에서 정보를 가져오는 과정을 보겠다.
하지만, OAuth2.0을 사용하면 회원가입 과정이 없기 때문에, 처음 들어오는 정보는 자동 회원가입 해주는 과정을 넣겠다.

아래는 DefaultOAuth2UserService가 구현하고 있는 OAuth2UserService 이다.

@FunctionalInterface
public interface OAuth2UserService<R extends OAuth2UserRequest, U extends OAuth2User> {

	U loadUser(R userRequest) throws OAuth2AuthenticationException;

}

이것에 맞게 구성해보겠다.
아래는 내가 개인적으로 만들고 있는 프로젝트에서 가져온 내용이다.

@Service
@RequiredArgsConstructor
public class PrincipalOAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // 부모 클래스의 loadUser 메서드를 호출하여 OAuth2 인증된 사용자 정보를 가져옴
        OAuth2User oAuth2User = super.loadUser(userRequest);

        // OAuth2UserInfo 인터페이스 구현체
        OAuth2UserInfo oAuth2UserInfo = null;

        // 클라이언트 등록 ID 가져오기
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        // 클라이언트 등록 ID에 따라 적절한 OAuth2UserInfo 객체 생성
        switch (registrationId) {
            case "facebook" -> oAuth2UserInfo = new FacebookUserInfo(oAuth2User.getAttributes());
            case "google" -> oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
            case "naver" -> oAuth2UserInfo = new NaverUserInfo((Map) oAuth2User.getAttributes().get("response"));
        }

        // OAuth2UserInfo를 기반으로 사용자 엔티티 생성 또는 업데이트
        User userEntity = saveOrUpdate(oAuth2UserInfo);

        // PrincipalDetails 객체를 사용자 엔티티와 OAuth2 사용자의 속성으로 생성하여 반환
        return new PrincipalDetails(userEntity, oAuth2User.getAttributes());
    }


    private User saveOrUpdate(OAuth2UserInfo oAuth2UserInfo) {
        
        User user = userRepository.findByEmail(oAuth2UserInfo.getEmail())  
                .map(User::updateModifiedDateIfUserExists)  // 이미 있다면 사용자 접근 시간 업데이트
                .orElse(new UserDto.Request().toEntity(oAuth2UserInfo, bCryptPasswordEncoder)); // 없다면 새로 만들기.
        userRepository.save(user);

        return user;
    }
}

💡마치며

Spring Security를 통해서 직접 세션을 만들어 Cookie에 저장하지 않고, 보안적인 측면에서도 직접 등록하는 것보다 우수하고 편리하단걸 알게 되었다.
그 중에서도 편리한건 사이트 접근권한 '인가' 기능 이다.
Spring Security를 사용하지 않는다면, Filter 또는 Interceptor를 통해서 권한이 없는 사용자의 접근을 처리했지만, requestMatchers을 통해서 접근을 처리할 수 있다.

Security가 복잡하고 보안적인 문제와 가장 밀접하기 때문에 중요하고, 깊게 공부해야 하기 때문에, 개인적인 공부로는 부족함을 느낄 수 있다고 생각한다.
그래서, 실무에 나가 Security에 대해서 더욱 자세히 공부를 해봐야 겠다는 생각이 들었다.

다음에는 JWT를 FormLogin, OAuth2.0에 적용하는 공부를 할 것이다.

profile
Better than yesterday.

0개의 댓글