본문 바로가기
IT/Spring boot

[Spring boot] Spring Security 기본 구현

by kyu-nahc 2024. 9. 5.
반응형

Spring Security

이전 포스트에서는 Spring Security의 기본 구조에 대해서 살펴보았다.

Spring Security의 기본 구조에 대한 내용은 아래 포스트를 참고하면 된다.

 

[Spring boot] Spring Security 구조 이해

Spring SecuritySpring Security는 애플리케이션 보안(인증, 인가, 권한)을 담당하는 스프링 하위 프레임워크이다.보안 옵션을 많이 제공해 주고 복잡한 로직 없이도 어노테이션으로도 설정이 가능하며,

kyu-nahc.tistory.com

해당 포스트에서는 Spring Security에서 기본으로 제공하는 세션 인증 기반 코드이다.

Spring Security를 사용하여 Jwt Server 혹은 OAuth 소셜 로그인을 구현할 수 있으며,

이에 대한 구현 예제는 다음 포스트에서 작성할 예정이다.

먼저 개발 환경부터 살펴보면 다음과 같다.

 

개발 환경

  • Spring boot 3.3.3
  • Java 21
  • Gradle
  • Spring Security / Jpa
  • Maria Database

 

Member.java / MemerRepository.java

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private String email;
    private String role;
    @CreationTimestamp
    private Timestamp createDate;
}
public interface MemberRepository extends JpaRepository<Member,Long> {
    Optional<Member> findByUsername(String username);
}

 

먼저 로그인 및 회원가입을 진행해야 하기 때문에 Member Entity부터 만들어준다.

Spring Data Jpa 의존성을 사용하므로 @Id를 통해 Primary_Key를 명시해 주고,

@Entity를 통해 데이터베이스에 대응하는 하나의 클래스임을 명시하고, 

Jpa에서 관리하도록 해준다. 그 후 JpaRepository를 상속받는 MemberRepository를 생성하고,

UserDetailsService를 구현하는 구현체에서 사용할 findByUsername() 메소드를 선언한다.

 

PrincipalDetails.java

@Data
public class PrincipalDetails implements UserDetails {

    private Member member;

    public PrincipalDetails(Member member){
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collect = new ArrayList<>();
        collect.add((GrantedAuthority) () -> member.getRole());
        return collect;
    }

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

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

}

 

다음으로 UserDetails를 구현하는 구현체인 PrincipalDetails Class를 만들어준다.

Spring Security 구조에서 설명한 것처럼 UserDetails 객체는

SecurityContextHolder의 가장 내부에 담겨 있는 인증된 유저 객체이다.

따라서 해당 Class를 통해 유저 객체에 대해 접근할 수 있는 것이다.

생성자 매개변수로 Member 객체를 받아서 필요한 메소드를 구현해 준다.

기본적인 getter로는 getUsername(), getPassword(), getAuthorites() 등이 있으며,

만약 추가적으로 Member 멤버변수에 접근해야 한다면 getter 메소드를 구현해 주면 된다.

 

isEnabled()는 예를 들어 휴먼 계정에 대한 설정을 하는 것으로,

만약 해당 웹사이트를 1년 동안 회원이 로그인을 안 한다면 휴먼 계정으로 취급하고 싶다고 할 때,

( 현재 시간 ) - ( 로그인 시간 )이 1년을 초과하면 return false를 하는 것과 같은 설정을 하는

메소드이다. 현재는 boolean을 return 하는 메소드 모두 true를 return 하도록 설정하면 된다.

 

PrincipalDetailsService.java

@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username)
            throws UsernameNotFoundException {
        Optional<Member> memberEntity = memberRepository.findByUsername(username);
        return memberEntity.map(PrincipalDetails::new).orElse(null);
    }
}

 

PrincipalDetailsServce는 UserDetailsService 인터페이스의 구현체로

loadUserByUsername() 메소드를 구현해야 한다.

이는 AuthenticationProvider가 인증 공급을 하기 위해 사용한다.

PrincipalDetailsServce에서 전달된 요청 아이디를 이용하여 해당 사용자를 데이터베이스에서 찾고,

UserDetails의 구현체인 PrincipalDetails 객체를 생성하여 반환만 해주면 된다.

 

반환된 PrinicipalDetails 객체를 이용하여,

비밀번호 검증 및 인증 절차 로직에 대해서는 Spring Security가 진행한다.

나중에 보겠지만 SecuriyConfig 파일에서 loginProcessingUrl을 걸어놨다면,

loginProcessingUrl에서 지정한 Url로 로그인 요청이 오면,

자동으로 UserDetailsService 타입으로 Ioc 되어있는 loadUserByUsername이 실행되므로,

@Service를 통해 Spring Bean으로 등록하고 loadUserByUsername() 메소드만 구현해 주면 된다.

만약 인증 절차가 완료된다면, return 한 PrincipalDetails 객체가

Authentication 객체 내부에 들어가게 되고, SecurityContext에 들어간 후,

최종적으로 SecurityContextHolder에 들어가게 되어 로그인한 상태가 유지된다.

즉 최종 인증까지 가는 과정은 필터와 여러 Provider에 의해 복잡하지만,

Spring Security에서 직접 구현할 코드들은 매우 적고,

권한 인가 기능까지 해주기 때문에 원리만 이해한다면 쉽게 구현할 수 있다.

 

SecurityConfig.java

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    // 비밀번호 암호화를 위한 Bean 객체 생성
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    
    // Custom SecurityFilterChain 등록
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{

        String loginErrorMessage = URLEncoder.encode(ErrorEncodedString.INVALID_CREDENTIALS.getMessage(), StandardCharsets.UTF_8);

        http
                .csrf(AbstractHttpConfigurer::disable)
                .headers((headerConfig) ->
                        headerConfig.frameOptions(
                                HeadersConfigurer.FrameOptionsConfig::disable
                        )
                )
                .authorizeHttpRequests((authorizeRequests) ->
                        authorizeRequests
                                .requestMatchers("/auth/**","/public/**",
                                "/css/**","/js/**").permitAll()
                                .requestMatchers("/api/user/**").authenticated()
                                // hasRole을 통해 권한 부여
                                .requestMatchers("/api/manager/**").hasAnyRole("ADMIN", "MANAGER")
                                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                                .anyRequest().authenticated()
                )
                .exceptionHandling((exceptionConfig) ->
                    exceptionConfig
                            .accessDeniedHandler((request, response, accessDeniedException) -> {
                                response.sendRedirect("/public/except");
                            })
                )
               .formLogin((formLogin) ->
                        formLogin
                                .loginPage("/public/login")
                                .loginProcessingUrl("/auth/sign-in")
                                .usernameParameter("username")
                                .passwordParameter("password")
                                .defaultSuccessUrl("/public/main",true)
                                .failureUrl("/public/login?error=true&errorMessage="+loginErrorMessage)
                                .permitAll()
                )
                .logout((logoutConfig) ->
                        logoutConfig
                            .logoutUrl("/auth/logout")
                            .deleteCookies("JSESSIONID")
                            .invalidateHttpSession(true)
                            .logoutSuccessUrl("/public/main")
                )
        return http.build();
    }
}

 

다음으로 Spring Security에 대한 설정 부분이다.

@EnableWebSecurity의 역할은 Spring Security Filter가 Spring 필터 체인에 등록된다.

기본 설정의 Spring Security Filter Chain이 아닌 생성한 것이 작동되도록 한다.

또한, 모든 요청 URL이 Spring Security 제어를 받도록 만들고,

내부적으로  등록한 SpringSecurityFilterChain이 동작하여 URL 필터가 적용되는 것이다.

따라서 SecurityFilterChain을 Custom 하여 Bean 객체로 생성할 필요가 있다.

또한 비밀번호 암호화를 위해 Spring Security에서 제공하는

PassEncoder를 Bean으로 등록해 주면 비밀번호를 암호화하여 저장할 수 있다.

 

@EnableMethodSecurity의 역할을 @Secured 어노테이션을 활성화시켜 주며,

Controller Method에 사용하면 유용하다. 또한 @preAuthorize 등의 어노테이션을 활용가능한데,

이는 특정 권한을 가진 경우만 접근가능하도록 해준다.

원하는 설정에 대해 SecurityFilterChain을 애플리케이션에 맞게 변경하여 Bean으로 등록해 주면 된다.

각 chaining 메소드들을 자세히 살펴보면 다음과 같다.

 .csrf(AbstractHttpConfigurer::disable)

 

CSRF(Cross-Site Request Forgery)는 웹 애플리케이션의 취약점 중 하나로, 

사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 하도록 만드는 공격이다. 

이 설정은 CSRF 보호 기능을 비활성화한다. 

이렇게 설정하면, CSRF 토큰 없이도 요청을 처리할 수 있게 된다.

.headers((headerConfig) ->
        headerConfig.frameOptions(
                HeadersConfigurer.FrameOptionsConfig::disable
        )
)

 

Spring Security는 기본적으로 X-Frame-Options 응답해더를 DENY로 설정한다.

<frame>이나 <iframe> 태그로 페이지를 표현하는 부분은 보일 수 없음을 의미한다.

이렇게 설정되어 있는 이유는 사이트를 Clickjacking 공격으로부터 보호하기 위함이다.

따라서 현재는 Maria Database를 사용하므로 해당 설정을 diable()할 필요는 없지만,

만약 h2-console을 사용한다면 h2-console은 <frame> 태그를 사용하므로 해당 설정을 disable() 한다.

.authorizeHttpRequests((authorizeRequests) ->
        authorizeRequests
                .requestMatchers("/auth/**","/public/**",
                "/css/**","/js/**").permitAll()
                .requestMatchers("/api/user/**").authenticated()
                // hasRole을 통해 권한 부여
                .requestMatchers("/api/manager/**").hasAnyRole("ADMIN", "MANAGER")
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
)

 

해당 설정은 HTTP 요청에 대한 인증 및 권한을 정의한다.
Url Pattern에 따라 정의할 수 있으며,

permitAll()은 해당 Url Pattern에 대해서는 접근을 가능하게 한다는 것이다.

authenticated()는 인증이 완료되면 즉 로그인을 하면 접근이 가능하다는 것이고,

hasAnyRole()은 유저의 권한이 ADMIN 혹은 MANAGER인 경우 해당 Url에 접근할 수 있다는 것이다.

주의할 점은 hasAnyRole() 혹은 hasRole()에서는 "ROLE_"자체가 Prefix로 붙기 때문에

데이터베이스에 ROLE_USER, ROLE_MANAGER, ROLE_ADMIN 이런 식으로 저장되어 있다면,

hasAnyRole(), hasRole()에서는 "ROLE_" 구문을 제외한 ADMIN, USER 이렇게만 작성하면 된다.

hasRole()은 해당 권한이 있는 경우에만 접근할 수 있도록 해준다.

그리고 anyRequest().authenticated() 메소드는

위의 설정의 모든 Url Pattern을 제외하고는 인증을 거쳐야 접근하도록 하겠다는 의미이다.

.exceptionHandling((exceptionConfig) ->
    exceptionConfig
            .accessDeniedHandler((request, response, accessDeniedException) -> {
                response.sendRedirect("/public/except");
            })
)

 

해당 메소드는 Spring Security에서 발생하는 에러에 대한 처리이다.

Spring Security는 기본적으로 인증 절차에 대한 에러와 권한 절차에 대한 에러를 내뱉는다.

따라서 이를 Custom 하여 다른 페이지로 이동시키거나 특정 메시지를 전달하고 싶다면,

accessDeniedHandler(), authenticationEntryPoint()를 통해 제어할 수 있다.

accessDeniedHandler()권한이 없는 사용자가 특정 권한이 필요한 Url에  접근 시 내뱉는 에러이고,

authenticationEntryPoint()인증 실패 및 인증이 필요한 Url에 접근 시 내뱉는 에러이다.

.formLogin((formLogin) ->
    formLogin
            .loginPage("/public/login")
            .loginProcessingUrl("/auth/sign-in")
            .usernameParameter("username")
            .passwordParameter("password")
            .defaultSuccessUrl("/public/main",true)
            .failureUrl("/public/login?error=true&errorMessage="+loginErrorMessage)
            .permitAll()
)
.logout((logoutConfig) ->
    logoutConfig
        .logoutUrl("/auth/logout")
        .deleteCookies("JSESSIONID")
        .invalidateHttpSession(true)
        .logoutSuccessUrl("/public/main")
)

 

다음으로 로그인 및 로그아웃에 대한 설정이다.

Spring Security의 의존성을 사용하고, 만약 아무 설정 없이 애플리케이션을 시작한다면,

모든 Url에 대해 다음과 같은 페이지를 접하게 된다.

 

해당 로그인 페이지를 Custom 하고,

로그인 성공 시 redirect 할 Url 및 실패했을 경우 redirect 할 Url 등을 설정하는 것이 .formLogin()이다.

.loginPage()에서는 직접 Custom 한 Login View를 return 하는 컨트롤러의 Url을 적어준다.

그러면 위와 같은 Default Login View가 아닌 내가 작성한 로그인 페이지와 연결시켜 준다.

.loginProcessingUrl() 에서는

<form action="" method="post"/>에서 action에 들어갈 url을 적어주면 된다.

method는 반드시 Post로 지정하며 해당 form에서 버튼을 클릭하여 로그인을 시도할 경우

action에 적은 Url에 따라 Spring Security의 인증 절차와 연결된다.

<form action="/auth/sign-in" method="post">
    <label for="username"> User Id </label> <br>
    <input id="username" type="text" name="username" placeholder="User Id"/> <br>

    <label for="password"> User Password </label> <br>
    <input id="password" type="password" name="password" placeholder="User Password"> <br>
    <button>로그인</button>
    <div class="error-message-wrapper" th:if="${errorMessage != null}">
        <p class="error-message" th:text="${errorMessage}"></p>
    </div>
</form>

 

다음으로 .username() .password()이다.

다음과 같은 HTML이 있을 때,  <input />에 존재하는 name과 mapping 하기 위해 존재한다.

.username("username") 이므로, <form> 태그 안에 존재하는

<input name="username"/>의 값에 Mapping 되는 것이다.

.password()도 마찬가지이다.

.defaultSuccessUrl().failureUrl()로그인 실패와, 로그인 성공 시 redirect 할 Url을 적어준다.

이제 로그아웃을 살펴보면 다음과 같다. 먼저 .logoutUrl()이다.

<a sec:authorize="isAuthenticated()" href="/auth/logout">로그아웃</a>

 

다음과 같이 <a href=""> 태그에 명시되어 있는 href 태그와 동일하게 적어주면 된다.

.deleteCookies().invalidateHttpSession()

현재까지 저장된 Session 정보에 대한 삭제를 의미한다.

Spring Securiy는 기본적으로 Session 기반 인증 방식이므로 쿠키에 JSESSIONID가 존재한다.

따라서 로그아웃 시 해당 쿠키도 삭제해 준다.

.logoutSuccessUrl()로그아웃 시 redirect 할 Url을 적어준다.

여기까지가 Spring Securiy를 사용할 때 기본적인 코드들이다.

마지막으로 Controller를 살펴보면 다음과 같다.

 

MainController.java  /  ApiController.java

@Controller
@RequiredArgsConstructor
public class MainController {

    private final MemberService memberService;

    @GetMapping("/public/login")
    public String loginPage(
            @RequestParam(value="errorMessage",required=false) String errorMessage,
            @RequestParam(value = "error", required = false, defaultValue = "false") boolean error,
            Model model){
        if (error) {
            model.addAttribute("errorMessage", errorMessage);
        }
        return "login.html";
    }

    @GetMapping("/public/main")
    public String index(){
        return "main.html";
    }

    @GetMapping("/public/join")
    public String join(
            @RequestParam(value="errorMessage",required=false) String errorMessage,
            @RequestParam(value = "error", required = false, defaultValue = "false") boolean error,
            Model model){
        if (error) {
            model.addAttribute("errorMessage", errorMessage);
        }
        return "join.html";
    }

    @GetMapping("/public/except")
    @ResponseBody
    public String except(){
        return "Not Access This Page";
    }

    @PostMapping("/auth/join")
    public String joinProc(Member member){
        String result = memberService.joinProcess(member);
        String errorMessage;
        if (result.equals("Join Success")){
            return "redirect:/public/login";
        }else if (result.equals("ID_DUPLICATED")){
            errorMessage = URLEncoder.encode(ErrorEncodedString.ID_DUPLICATED.getMessage(), StandardCharsets.UTF_8);
        }else{
            errorMessage = URLEncoder.encode(ErrorEncodedString.FAIL_JOIN.getMessage(), StandardCharsets.UTF_8);
        }
        return "redirect:/public/join?error=true&errorMessage="+errorMessage;
    }
}

 

MainController에서는 기본적인 인증이 필요 없는 View를 제공하는 컨트롤러이다.

위에서 설명한 loginPage(), failureUrl(), accessDeniedHandler()에

등록한 Url의 Url Pattern 들의 처리를 담당하는 컨트롤러이다.

@RestController
@RequestMapping("/api")
@Slf4j
public class ApiController {

    @GetMapping("/user")
    public String user(@AuthenticationPrincipal PrincipalDetails userDetails){
        log.info(userDetails.getUsername());
        return "user";
    }

    @GetMapping("/manager")
    public String manager(@AuthenticationPrincipal PrincipalDetails userDetails){
        log.info(userDetails.getUsername());
        return "manager";
    }

    @GetMapping("/admin")
    public String admin(@AuthenticationPrincipal PrincipalDetails userDetails){
        log.info(userDetails.getUsername());
        return "admin";
    }
}

 

해당 컨트롤러는 권한에 따라 데이터를 제공하는 Rest Api

여기서 권한에 대한 테스트를 진행할 수 있다.

여기서 하나 살펴볼 것은 @AuthenticationPrincipal이다.

해당 어노테이션을 통해 세션 정보 접근 가능이 가능하다. 

즉 PrinicipalDetails는 UserDetails를 구현한 구현체이므로,

PrinicipalDetails 객체를 이용하여 로그인한 유저 정보에 대한 데이터를 접근할 수 있다.
다음으로 컨트롤러 메소드 자체에 권한 부여를 하는 방법을 살펴보면 다음과 같다.

@Secured("ROLE_ADMIN")
@GetMapping("/info")
@ResponseBody
public String infoPage(){
    return "User Info";
}

@PreAuthorize("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
@GetMapping("/data")
@ResponseBody
public String dataPage(){
    return "User Data";
}

 

@Secured특정 컨트롤러에 붙일 수 있으며, 특정 권한을 가질 경우만 접근이 가능하다.

@PreAuthorize는 해당 메소드가 실행되기 전에 실행되며,

@Secured와 다르게 2개 이상의 권한을 부여할 때 사용하면 유용하다.

마찬가지로 @PostAuthorize도 존재하는데 이는 메소드가 실행된 후에 실행된다.

즉 2개의 차이는 전처리, 후처리의 차이이다.

이 어노테이션들은 Url Pattern이 아닌

메소드 한 개에만 간단하게 권한 부여할 때 편리하게 사용할 수 있다.

지금까지가 Spring Security를 사용하는 기본적인 코드이고,

이를 활용하여 OAuth 소셜 로그인 및 Jwt를 활용하는 토큰 인증 기반까지 구현할 수 있다.

반응형

loading