1. 개요

Spring Security를 이용해 로그인 기능 구현방법에 대한 내용이다. 로그인 기능을 위해서만을 위해 스프링 시큐리티를 적용하는 것이 애플리케이션이 무거워질 수 있다고는 하지만 일단 한번 경험해보면 좋을 것 같아서 스프링 시큐리티를 적용해보았다.

 

- Spring Security란?

스프링 시큐리티는 스프링 생태계에서 사용자 인증/권한 관리/보안 등의 기능을 효율적이고 신속하게 구현할 수 있게 도움을 주는 스프링 프레임워크 중 하나이다. 다양한 인증방식 중에 principal(username)-credential(password) 패턴을 사용하고 있다.

 

Spring Security 특징

  • Filter를 기반으로 동작
    • Spring MVC와 분리되어 관리하고 동작할 수 있다.
  • Bean으로 설정가능
    • Spring Security 3.2부터는 XML 설정하지 않아도 됨

 

- Spring Security Architecture

 

구체적인 과정은 다음과 같다.

 

1. 사용자 로그인 정보가 담긴 HTTP 요청이 서버로 들어온다.

2. AuthenticationFilter가 요청을 가로채고, 가로챈 정보를 통해 UsernamePasswordAuthenticationToken의 인증용 객체를 생성

3. Filter를 통해 AuthenticationToken을 AuthenticationManager로 위임한다.

4. AuthenticationManager는 등록된 AuthenticationProvider들을 조회하며 인증을 요구한다.

5. AuthenticationProvider는 UserDetailsService를 통해 입력받은 사용자 정보를 DB에서 조회한다.

  • supports() 메서드를 통해 실행 가능한지 체크
  • authentication() 메서드를 통해 DB에 저장된 이용자 정보와 입력한 로그인 정보 비교
    • DB 이용자 정보 : UserDetailsService의 loadUserByUsername()을 통해 불러옴
    • 입력 로그인 정보 : (4)애서 받았던 Authentication 객체(UsernameAuthentication Token)
  • 일치하는 경우 Authentication 반환

6. AuthenticationManager는 Authentication 객체를 AuthenticationFilter로 전달한다.

7. AuthenticationFilter는 전달받은 Authentication 객체를 LoginSuccessHandler로 전송하고, SecurityContextHolder에 정보를 저장한다. 

 

2. 과정

 

2-1. 의존성 추가

build.gradle

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

 

2-2. SecurityConfig

전체 코드

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    // authenticationManager 를 Bean 등록
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration authenticationConfiguration
    ) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.httpBasic(HttpBasicConfigurer::disable);
        http.csrf(AbstractHttpConfigurer::disable);
        http.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorize-> authorize
                                                .requestMatchers(new AntPathRequestMatcher("/index")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/sign-up")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/sign-in")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/denied-page")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/v1/**")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/configuration/ui")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/configuration/security")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/webjars/**")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/static/**")).permitAll()
                                                .requestMatchers(new AntPathRequestMatcher("/ws-stomp/**")).permitAll()
                                                .anyRequest().authenticated())
            .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                                UsernamePasswordAuthenticationFilter.class)
            .logout(logout -> logout
                                .logoutUrl("/logout")
                                .logoutSuccessUrl("/index")
                                // 로그아웃 핸들러 추가 (세션 무효화 처리)
                                .addLogoutHandler((request, response, authentication) -> {
                                    HttpSession session = request.getSession();
                                    session.invalidate();
                                })
                                // 로그아웃 성공 핸들러 추가 (리다이렉션 처리)
                                .logoutSuccessHandler((request, response, authentication) ->
                                        response.sendRedirect("/index"))
                                .deleteCookies("JSESSIONID", "access_token"));

        return http.build();
    }
}

 

코드 톺아보기 - filterChain()

Spring Security 6.0.0 이후부터 WebSecurityConfigurerAdapter는 deprecated 되었기에 FilterChain의 역할을 하는 메서드를 직접 구현해 Bean으로 등록해 주어야 한다.

 

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {    
    http.httpBasic(HttpBasicConfigurer::disable);
    http.csrf(AbstractHttpConfigurer::disable);
    http.sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
         .authorizeHttpRequests(authorize-> authorize
                                .requestMatchers(new AntPathRequestMatcher("/index")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/sign-up")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/sign-in")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/denied-page")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/v1/**")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/configuration/ui")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/configuration/security")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/webjars/**")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/static/**")).permitAll()
                                .requestMatchers(new AntPathRequestMatcher("/ws-stomp/**")).permitAll()
                                .anyRequest().authenticated())
        .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                         UsernamePasswordAuthenticationFilter.class)
                         
	....
}

 

  • http.httpBasic(HttpBasicConfigurer::disable) : JWT토큰을 사용하는 REST API 방식이기에 disable 처리
  • http.csrf(AbstractHttpConfigurer::disable) : REST API 형식으로 JSON으로만 데이터를 주고 받는 Stateless한 통신방식을 사용하기에 csrf 설정이 불필요해 disable 처리
  • http.sessionManagement() : 기존의 .sessionManagement().sessionCreationPolicy()가 deprecated된 후 개정된 코드이다.
  • .authorizeHttpRequests() : 토큰 없이도 접근이 허용되는 url을 설정할 수 있고, 허용 설정을 하지 않은 url은 모든 인증단계를 거쳐야 접근할 수 있다.
  • .addFilterBefore(filter A, filter B) : B 체인 전에 A 체인을 추가하는 역할의 메서드이다. 순서가 (JwtAuthenticationFilter(), UsernamePassword~.class) 이므로 UsernamePasswordAuthenticationFilter 전에 JwtTokenProvider를 실행한다. 즉, JwtFilter를 통과하면 UsernamePasswordAuthenticationFilter 이후의 필터는 통과한 것을 의미한다. 쉽게 말해 JWT를 통해 Username + Password 인증 수행을 의미한다. 

 

2-3. JwtAuthenticationFilter

  • 이 클래스는 클라이언트 요청시 JWT 인증을 하기 위해 설치하는 커스텀 필터
  • UsernamePasswordAuthenticationFilter 이전에 실행된다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public void doFilter(ServletRequest request,
                         ServletResponse response,
                         FilterChain chain) throws IOException, ServletException {

        // 1. Request Header 에서 JWT 토큰 추출
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

        // 2. validateToken 으로 토큰 유효성 검사
        if (token != null && jwtTokenProvider.isValidateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
}

JwtTokenProvider의 resolveToken()으로 토큰을 추출한 뒤, 그 유효성을 isValidateToken()으로 확인한다. 모든 검사가 통과되면 다음단계로 넘어간다.

 

2-4. JwtTokenProvider

JWT 토큰을 발급하고, 추출하고, 유효한지 확인하는 클래스이다.

 

@Slf4j
@Component
public class JwtTokenProvider {

    @Value("${jwt.secret}")
    private String secretKey;
    private final static long TOKEN_VALID_TIME = 30 * 60 * 1000L; // 토큰 유효시간 30분
    private UserDetailsService userDetailsService;

    public JwtTokenProvider(@Lazy UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    // 객체 초기화, secretKey 를 Base64로 인코딩
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // JWT 토큰 생성
    public TokenResponseDto createToken(String email, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(email);
        claims.put("roles", roles); // 정보 저장 (key-value)
        Date now = new Date();
        return new TokenResponseDto(Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + TOKEN_VALID_TIME)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과 signature에 들어갈 secret 값 세팅
                .compact());
    }

    // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails =
                userDetailsService.loadUserByUsername(this.getUserEmail(token));
        return new UsernamePasswordAuthenticationToken(
                userDetails,
                "",
                userDetails.getAuthorities()
        );
    }

    // 토큰에서 회원 정보 추출
    public String getUserEmail(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    public String resolveToken(HttpServletRequest request) {
        String cookie = request.getHeader("Cookie");
        if (cookie != null) {
            String bearerToken = cookie.split("access_token=")[1];
            if (StringUtils.hasText(bearerToken)) {
                return bearerToken;
            }
        }

        return null;
    }

    // 토큰의 유효성 + 만료일자 확인
    public boolean isValidateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().
                            setSigningKey(secretKey).
                            parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

 

 

코드 톺아보기

@Value("${jwt.secret}")

jwt:
  secret: 비밀키

secretKey는 JWt의 signature 부분을 암호화할 개인 시크릿키를 설정해주는 값이다. 값이 비밀로 유지되어야 하므로 yml파일에 jwt.secret이라는 이름으로 .gitignore 처리해서 저장해서 사용하면 된다. 그 후 @Value("${jwt.secret}")으로 런타임에 키를 가져와 사용할 수 있게 하면된다.

 

createToken()

JWT 토큰을 생성하기 위한 메서드로, AuthService에서 로그인하는 함수인 signIn()에서 사용된다.

// JWT 토큰 생성
public TokenResponseDto createToken(String email, List<String> roles) {
     Claims claims = Jwts.claims().setSubject(email);
     claims.put("roles", roles); // 정보 저장 (key-value)
     Date now = new Date();
     return new TokenResponseDto(Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + TOKEN_VALID_TIME)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과 signature에 들어갈 secret 값 세팅
                .compact());
}
  • setSubject() : JWT의 목적 같은 내용 추가
  • setClaim : JWT 토큰의 payload에 들어갈 내용으로, key-value 형태로 저장
  • signWith : JWT토큰의 signature를 의미하며, HS256으로 인코딩하고 secretkey를 이용한다는 것을 의미한다

 

getAuthentication()

// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
    UserDetails userDetails =
        userDetailsService.loadUserByUsername(this.getUserEmail(token));
        
        return new UsernamePasswordAuthenticationToken(
                userDetails,
                "",
                userDetails.getAuthorities()
        );
    }
  • UserDetailsService.loadUserbyUsername(this.getUserEmail(token)) : 토큰으로 사용자 이메일을 추출한 뒤 사용자 정보를 가져온다
  • 인증용 객체인 UsernamePasswordAuthenticationToken을 반환한다(시큐리티 동작 과정중 (2)에 해당하는 내용)

 

getUserEmail()

매개변수로 입력된 token을 파싱해서 회원정보를 반환하는 함수이다.

// 토큰에서 회원 정보 추출
public String getUserEmail(String token) {
    return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
}

 

resolveToken()

HTTP request 헤더 Cookie에 있는 access token을 추출하는 메서드이다. 내가 구현한 애플리케이션에는 헤더 쿠키에 'access_token= ~'형태로 access token이 있기에 'access_token='으로 split한 문자열에서 쿠키를 가져온 뒤 bearerToken이 있다면 토큰을 반환하는 방식으로 구현했다.

public String resolveToken(HttpServletRequest request) {
    String cookie = request.getHeader("Cookie");
    if (cookie != null) {
        String bearerToken = cookie.split("access_token=")[1];
        if (StringUtils.hasText(bearerToken)) {
           return bearerToken;
        }
    }
        return null;
    }

 

 

isValidateToken()

유효한 토큰인지, 토큰이 만료되지 않았는지 확인하는 함수이다. boolean 값을 반환하므로 메서드명을 is~()형태로 명명했다.

(보니까 유효성에 대한 코드는 없는 것 같은데, 유효한지는 어떻게 확인하지?)

// 토큰의 유효성 + 만료일자 확인
public boolean isValidateToken(String jwtToken) {
     try {
        Jws<Claims> claims = Jwts.parser().
                            setSigningKey(secretKey).
                            parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date()); // 만료일자 확인
    } catch (Exception e) {
            return false;
}

 

2-5. CustomUserDetailsService

UserDetailsService 인터페이스를 구현한 커스텀 서비스이고, JwtTokenProvider 클래스의 getAuthentication()메서드 내부에서 사용하는 클래스이다. 여기에서는 http 요청으로 넘어온 사용자 정보가 서버에 존재하는 사용자인지 확인하고 다음과 같은 결과를 반환한다. 

  • 성공 시 UserDetails 타입의 객체를 반환
  • 실패 시 UsernameNotFoundException을 반환
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return memberRepository
                .findByEmail(username)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다."));
    }

    // 해당하는 Member 의 데이터가 존재한다면 User 객체로 만들어서 리턴
    private UserDetails createUserDetails(Member member) {
        return new SecurityUser(member);
    }
}

loadUserByUsername 내부를 보면 memberRepository에서 이메일로 사용자를 찾고, 그 결과를 SecurityUser로 매핑해서 반환하는 것을 볼 수 있다(람다와 스트림을 사용했다). 만약 사용자가 존재하지 않는다면 예외를 throw 한다.

 

2-6. SecurityUser

위 커스텀 서비스의 createUserDetails() 메서드에서 반환하는 타입으로, 시큐리티의 User를 상속받고 있다. 매개변수로 넘어온 Member 객체의 정보로 User 생성자를 호출해 User 객체를 생성한다.

@Getter
public class SecurityUser extends User {

    private Member member;

    public SecurityUser(Member member) {
        super(member.getEmail(), member.getPassword(),
                AuthorityUtils.createAuthorityList(member.getRole().toString()));
        this.member = member;
    }
}



참고

 

+ Recent posts