[Spring Security] 웹사이트 자체 로그인 구현하기

2023. 1. 12. 18:30Spring

Spring Security는 애플리케이션에 대한 인증, 권한 부여 등의 보안 기능을 제공하는 java 프레임워크다.

다음과 같은 이유로 Spring Security를 사용해보았다.

  1. 대략적인 애플리케이션 수준의 보안 아키텍처를 검증된 프레임워크인 Spring Security를 통해서 간접 학습
  2. OAuth2.0 이나 JWT 같은 기술에 대한 라이브러리를 포함하고 있어서 개발 비용 절약
  3. ioc 컨테이너를 이용하여 외부 리소스와 비즈니스 로직 분리

Spring Security란?

애플리케이션에 대한 인증, 권한 부여 등의 보안 기능을 제공하는 프레임워크다.

spring security는 다음과 같은 특징을 가진다.

세션-쿠키 인증 방식을 지원한다.

서버는 세션 저장소에 유저에 대한 정보를 저장하고 클라이언트는 session id를 쿠키(JSESSIONID)로 들고있다가 요청할 때마다 Http Request에 쿠키를 담아 보내서 서버가 이를 검증한다.

Filter의 흐름에 따라 처리한다.

DispatcherServlet과 Controller 사이에서 동작하는 Interceptor와 다르게 Filter는 Dispatcher Servlet으로 도달하기 전에 동작한다.

Spring Security Architecture

  1. AuthenticationFilter가 http request을 받음
  2. 요청에 담긴 아이디 비밀번호를 바탕으로 UsernamePasswordAuthenticationToken을 발급
    • username과 password의 유효성 검사 같이 Filter단에서 추가 로직을 추가하려면 UsernamePasswordAuthenticationFilter 를 상속 받는 custom 객체를 생성해야한다.
  3. 생성된 token을 AuthenticationManager에게 전달
  4. AuthenticationManager는 AuthenticationProvider들 중 해당하는 Provider에게 실제 인증 처리를 위임한다.
    • AuthenticationProvider를 상속 받는 구현체는 authenticate 함수를 overriding해야함
    • db에서 유저 정보를 조회해서 비밀번호가 일치하는지 확인함
  5. UserDetailsService가 아이디를 이용하여 DB로부터 유저 정보를 가져온다
    • UserDetailsService는 인터페이스이기 때문에 이를 implements 하는 클래스를 작성해야함
  6. UserDetailsService가 DB를 통해 조회한 데이터는 UserDetails 타입 객체에 담긴다.
  7. 데이터를 Provider에게 반환
  8. Provider가 UserDetailsService로부터 반환받은 비밀번호와 Token안의 비밀번호를 비교하여 일치하지 않는 경우 BadCredentialsException을 발생시킨다.
    • 성공시 Authentication 객체를 생성하여 결과를 반환한다.
  9. 인증 완료된 Token을 AuthenticationFilter에게 반환하고 LoginSuccessHandler에게 전달한다.
  10. LoginSuccessHandler는 Authentication 객체를 SpringContextHolder안에 저장한다.
    • SpringContext는 spring security만을 위해 할당된 서버 session 영역이다.

Spring Security 사용하기

  1. spring security 의존성 추가
  2. Spring Security Configuration 설정

1. spring security 의존성 추가

build.gradle

dependencies {
		implementation 'org.springframework.boot:spring-boot-starter-security'
}

2. Spring Security Configuration 설정

spring security 3.2부터는 자바 빈 설정을 지원한다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)  // 선택적으로 권한 처리시 @Secured나 @PreAuthorize 사용
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable()  // csrf 보안 설정 여부
                .authorizeRequests()  // url에 대한 인증 설정
                .antMatchers("/user/**").authenticated()  // 인증되어야만 url 정상 처리
                .antMatchers("/manager/**").access("hasRole('MANAGER') or hasRole('ADMIN')")  // 특정 권한이 있어야만 url 정상 처리
                .antMatchers("/admin/**").access("hasRole('ADMIN')")
                .anyRequest().permitAll()  // 그외는 인증을 요구하지 않음
                .and()
                .formLogin();  // spring security에서 제공하는 로그인 form을 사용함
        return http.build();
    }
}

예제 코드

PrincipalDetails

  • 자체 로그인시 UserDetails타입 객체를 사용한다.
import com.project.educationLab.domain.user.entity.User;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.ArrayList;
import java.util.Collection;

@Getter
public class PrincipalDetails implements UserDetails {
    
    private final User user;

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

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

        return collect;
    }

    @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;
    }
}

PrincipalDetailsService

자체 로그인시 동작하는 UserDetailsService타입 서비스 객체이다.

loadUserByUsername() 메서드를 오버라이딩한다.

package com.project.educationLab.domain.auth.service;

import com.project.educationLab.domain.auth.dto.PrincipalDetails;
import com.project.educationLab.domain.user.entity.User;
import com.project.educationLab.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

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

    // "/login" 요청시 자동으로 동작
    // 로컬 로그인시 결과가 UserDetails 타입 객체에 담김
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User find = userRepository.findByUsername(username).orElseThrow(() ->
                new UsernameNotFoundException("username is not found"));
        return new PrincipalDetails(find);
    }
}

서버 세션 정보 조회 방법

@Controller
@RequiredArgsConstructor
@Slf4j
public class AuthController {

    @GetMapping("/test/login")
    public @ResponseBody String testLogin(Authentication authentication) {
        log.info("/test/login==========");
        PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
        log.info("authentication : {}", principalDetails.getUser());
        return "세션 정보 확인하기";
    }
}

위와 같은 방법으로 서버 세션안의 Authentication안의 UserDetails 타입 객체의 유저 정보를 조회할 수 있다.

>> /test/login==========
>> authentication : User(id=12, username=testUser, password=$2a$10$BW1qmL3FW5nuRs2dHYoEIOZxgM9goOGoSa.vJlyFNfu4z7mIztC.6, email=test@naver.com, role=USER, providerId=null, createDate=null)

로그인 동작 테스트

위와 같이 로그인후 브라우저의 메모리 영역에 JSESSIONID라는 이름의 쿠키가 저장된 것을 확인할 수 있다.

정리

이번 글에선 Spring Security와 자체 로그인을 구현하는 법을 간단히 알아보았다.

다음 글에선 OAuth2를 이용한 소셜 로그인 구현 방법에 대해서 다뤄보겠다.

코드 참조

https://github.com/KIM-KYOUNG-OH/Education-Lab

Reference