Spring Boot3 이상 Spring Security 적용

강성준·2023년 10월 25일
0

1.Intro

필자는 Spring Security를 사용은 해보았지만 Legacy 프로젝트에서만 사용했기 때문에 WebSecurityConfigurerAdapter를 상속받아 구현하는데 그쳤었다.
그러다 WebSecurityConfigurerAdapter가 Deprecated되고 나서 Spring Boot에 Security를 적용하기 위해 여러 블로그를 찾아보며 적용해보았다.
참고한 게시글은 따로 기재하지 않겠다.

2.개발환경

  • Spring Boot 3.0.2
  • Spring Security 6.0.1
  • java 17
  • Gradle 7.6
  • IntelliJ IDEA 2023.2.3
  • JSP
  • JPA
  • H2Database

2-1.의존성

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.2'
    id 'io.spring.dependency-management' version '1.1.0'
}

group = 'com.***'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.apache.tomcat.embed:tomcat-embed-jasper'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'

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

tasks.named('test') {
    useJUnitPlatform()
}

3.Spring Security 설정

Spring Security 설정은 XML, Java Config로 작성할 수 있다.
필자는 Java Config로 진행하였다.

먼저 패키지 구분을 위해 config 패키지를 생성하고, SpringSecurityConfig 클래스를 생성하였다.

@Configuration
@EnableMethodSecurity
public class SpringSecurityConfig {
	....
}

@Configuration 어노테이션을 사용해 해당 클래스가 설정파일로써 빈으로 등록하겠다고 한다. component scan에 의해 빈으로 등록된다.
@EnableMethodSecurity 어노테이션을 사용하면 커스텀 어노테이션으로도 메서드 단위의 인가를 적용할 수 있다고 한다. (그리고 @EnableWebSecurity는 Spring Boot인 경우 자동 적용되어 설정할 필요가 없다고 어디선가 본 기억이 있다...)

위와 같이 어노테이션을 추가하였다면 Spring Security Java Config를 작성할 준비가 되었다.

일단 필자가 사용하던 WebSecurityConfigurerAdapterDeprecated 되었기 때문에 새로운 방법으로 작성하여야 한다.

3-1. SecurityFilterChain

WebSecurityConfigurerAdapter가 Deprecated 되어 현재는 SecurityFilterChain을 Bean으로 등록하여야한다.

@Configuration
@EnableMethodSecurity
public class SpringSecurityConfig {
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity) {
    ....
    }
}

위와 같이 FilterChain을 반환하는 메서드를 빈으로 등록하였다면 csrf, cors등의 설정을 아래와 같이 진행하자(csrf, cors에 대한 설명은 하지 않겠다.)

@Configuration
@EnableMethodSecurity
public class SpringSecurityConfig {
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity) {
    	http.csrf().disable().cors.disable()
        	.authorizeHttpRequest(request -> request	// http 요청에 대한 인가 설정 처리
            	.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll()
                .requestMatchers("/status", "images/**", "/view/join", "/auth/join").permitAll()
                .anyRequest().authenticated()
                
            )
    }
}

먼저 위 설정을 보자 http 요청에 대한 인가 설정을 위해 authorizeHttpRequest 메서드를 사용하였다. 그 안에는 dispatcherTypeMatchers 메서드를 사용하는데 View Resolver 등을 통해 페이지 이동을 하면 반드시 설정에 추가해주자(스프링 시큐리티 6.0부터 forward 방식 페이지 이동에도 default로 인증이 걸리도록 변경되었다고 한다.)
RequestMatcher를 사용하여 인가에 걸리지 않게 할 경로를 지정해주자(기억상 antMacher의 ignore를 사용하였었는데 권장하지 않는다고 한다.)

3-2.커스텀 로그인 페이지

커스텀 로그인 페이지를 사용하지 않으면 Spring Security에서 기본으로 제공하는 로그인화면을 사용하게 된다.(아무도 사용하지 않는다.)

해서 우리가 만든 로그인 페이지를 사용하고 싶다면 아래와 같이 설정을 추가한다.

@Configuration
@EnableMethodSecurity
public class SpringSecurityConfig {
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity) {
    	http.csrf().disable().cors.disable()
        	.authorizeHttpRequest(request -> request	// http 요청에 대한 인가 설정 처리
            	.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll()
                .requestMatchers("/status", "images/**", "/view/join", "/auth/join").permitAll()
                .anyRequest().authenticated()
                
            )
            .formLogin(login -> login		// form방식의 로그인사용
            	.loginPage("view/login")	// 우리가 만든 로그인 페이지
                .loginProcessingUrl("/login-process")	// submit 받을 url
                .usernameParameter("userid")
                .passwordParameter("pw")
                .defaultSuccessUrl("/view/dashboard", true)	// 로그인 성공시 이동될 페이지
                .permitAll()	// 로그인 성공시 이동될 페이지는 허용
            
            )
    }
}

설정을 살펴보면 formLogin 메서드 안에서 많은 메서드들 사용하고 있다.
여기서 특이한점으로 꼽자면 loginProcessingUrl일텐데 난 저런 엔드포인트를 구현해놓지 않았다. 그럼 어떻게 처리가 되는걸까?

<form class="form-signin" method="post" action="/login-process">
        <p>
            <label for="username" class="sr-only">아이디</label>
            <input type="text" id="username" name="userid" class="form-control" placeholder="아이디" required="" autofocus="">
        </p>
        <p>
            <label for="password" class="sr-only">비밀번호</label>
            <input type="password" id="password" name="pw" class="form-control" placeholder="비밀번호" required="">
        </p>
        <button class="btn btn-lg btn-primary btn-block" type="submit">로그인</button>
</form>

화면이 위와 같이 구성되어있다고 보자 form 태그내에 action에 설정에 해두었던 Url과 동일하게 /login-process가 들어가있다.
loginProcessingUrl 에 입력한 url로 submit을 하면 Security가 가로채 내부적으로 처리된다.

4.로그인

이제 우리가 만든 로그인 페이지를 사용할 수 있게 되었다.
예외나 인가에 대한 핸들러는 작성하지 않았지만 추후 작성하고 일단 로그인에 대한 부분을 더 살펴보자.

Spring Security는 로그인 된 유저의 정보를 UserDetails이라는 객체에 담아두고 있다. 이러한 객체를 우리는 유용하게 쓸 수 있다. 하지만 로그인 시 우리가 입력한 정보가 회원으로 가입이 되어있어야한다.

로그인 성공시 -> UserDetails 에 정보가 저장되고 로그인 처리됨
로그인 실패시 -> 예외 발생시킴

아래 코드를 살펴보자 먼저 로그인을 구현하기 위해선 UserDetailsService를 구현한 클래스를 생성해야한다.

@Component
public class MyUserDetailService implements UserDetailsService {

	private final MemberService memberService;
    
    @Autowired
    public MyUserDetailService(MemberService memberService) {
    	this.memberService = memberService;
    }
	
    @Override
    public UserDetails loadUserByUsername(String insertedUserId) throws UsernameNotFoundException {
    	Optional<Member> findOne = memberService.findOne(insertedUserId);
        Member member = findOne.orElseThrow(() -> new UsernameNotFoundException("없는 회원입니다."));
        
        return User.builder()
        	.username(member.getUserid())
            .password(member.getPw())
            .roles(member.getRoles())
            .build();
    }
}

코드의 첫줄 부터 살펴보자 @Component 어노테이션으로 Bean으로 등록하고 입력받은 회원이 DB에 저장되어있는(회원가입이 되어 있는) 회원 인지 확인하기 위해 MemberService를 생성자를 통해 자동주입 받는다.

그리고 UserDetailsService를 구현하기 때문에 loadUserByUsername 메서드를 구현하여야하는데 여기서 우리가 로그인 성공, 실패시의 코드를 작성한다.

코드를 보면 loadUserByUsername 메서드에 인자로 insertedUserId가 들어온다. 이것은 우리가 화면에서 입력한 아이디로 위에서 커스텀 로그인 페이지에서 설정한 usernameParameter()에 해당한다.

현재 코드에서는 Memeber Entity가 생성되있는 상태라 그냥 가져다 사용하였다. Member Eneity는 아래와 같이 구성되어있다.

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String userid;

    private String pw;

    private String roles;

    private Member(Long id, String userid, String pw, String roleUser) {
        this.id = id;
        this.userid = userid;
        this.pw = pw;
        this.roles = roleUser;
    }

    protected Member() {}

    public static Member createUser(String userId, String pw, PasswordEncoder passwordEncoder) {
        return new Member(null, userId, passwordEncoder.encode(pw), "USER");
    }
	
    .. 아래는 getter...
}

우리는 어쨋던 DB에 있는 회원을 조회하여 담을 그릇과 통로를 확보했다.
로그인이 성공하면 UserDetail에 회원 정보가 담길것이다.(Repository, MemberService는 따로 기록하지 않겠습니다.)

로그인이 성공하면 defaultSuccessUrl에 지정해둔 페이지로 이동할 것이다.

5.Role별 제한

Spring Security에서는 권한별 요청할 수 있는 엔드포인트, 메서드를 제한할 수 있다. 그래서 처음 설정에서 @EnabledMethodSecurity를 사용하여 커스텀 어노테이션으로 메서드별로 제한하기 위해 사용하였으나 FilterChain에서도 제한 할 수 있는 방법도 기록할것이다.

먼저 FilterChain에서 엔드포인트를 권한별로 제한할 수 있는 방법을 알아보자

5-1. FilterChain에서 role별 제한

public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
	http.csrf().disable().cors().disable()
    	.authorizeHttpRequests(request -> request
        	.requestMatchers("/status", "/images/**", "/view/join", "/auth/join").permitAll()
        )
}

위에서 우리는 이미와 같은 resource들은 Security에서 제외시키도록 .permitAll() 메서드로 모든 요청에 대하여 허용하였다. 이제 제한하기 위해서는 아래와 같이 적용하면 Role별로 요청을 제한할 수 있다.

public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
	http.csrf().disable().cors().disable()
    	.authorizeHttpRequests(request -> request
        	.requestMatchers("/status", "/images/**", "/view/join", "/auth/join").permitAll()
            .requestMatchers("/view/admin").hasRole("ROLE_ADMIN");
        )
}

현재 테이블에 ROLE의 종류가 ADMIN과 USER만 있다면
"/view/admin" 요청은 ROLE_ADMIN만 가능하다.
만약 허용해야할 권한이 여러개라면 hasAnyRole를 사용하면 된다.

※ DB에 ROLE 추가시 ROLE_을 붙이지 않아도 된다.(Spring Security에서 알아서 접두사로 추가해줌.)

profile
Java, Spring Framework로 백엔드 개발을 하는 개발자입니다.

0개의 댓글