프로젝트 Project

Spring Security + JWT로 로그인 구현하기 (1)

달래dallae 2024. 8. 5. 13:31

인증/인가 로직을 구현 할 때, 모든 기능을 직접 구현하는 것보다 Sprign security를 사용하면 간편하게 구현이 가능합니다.

저는 인가로직은 사용하지 않았고, Spring Security환경에서 JWT를 사용해 인증 로직을 어떻게 구현했는지 과정을 기록하려고 합니다.

 

0️⃣ 프로젝트 환경

  • Spring Boot 2.7
  • Gradle
  • Java 11, JPA
  • Rest API

✔️ 로그인 설계

  • 개인 서버를 이용하는 로그인
    • 일반 회원 로그인 / 관리자 로그인
  • oauth 서버를 사용하는 로그인
    • kakao 로그인/ google 로그인 / naver 로그인

일반 회원과 관리자는 개인 서버, 즉 우리의 DB를 사용하기 때문에 인증/인가에 대한 보안 전략이 필요합니다.

이 때 아이디/패스워드와 같은 민감한 회원 정보를 다루는 방법으로, JWT를 사용하겠습니다.

💡 인증
유저의 신원을 입증하는 과정이다.
💡 인가
권한에 대한 허가로서, 인증된 사용자에 대한 자원 접근 권한을 확인한다.

 


1️⃣ 인증 흐름

✔️ 인증이 필요하지 않은 API

회원가입이나 로그인, 그리고 단순한 조회용 API의 경우, 로그인한 사람의 정보가 없으므로 사용자 정보를 알아올 필요가 없습니다.

  • 회원가입
    1. 회원가입 API요청
    2. 회원가입 로직
    3. 성공 시 201 Created 상태코드 반환
  • 로그인
    1. 로그인 API 요청
    2. 아이디/패스워드 검증
    3. Access/Refresh Token 생성 + Refresh Token DB에 저장
    4. 토큰 정보 응답
  • 그 외 단순 조회 API

 

✔️ 인증이 필요한 API

게시글을 작성하거나, 수정하는 API는 로그인한 사용자에 대한 정보가 필요합니다.

이를 알아내기 위해 API 호출 전, Security Filter에서 등록한 JWT Filter에서 Access Token의 유효성을 검증하도록 합니다.

  • 인증 성공 시
    1. 토큰 정보로 Authentication 객체를 생성해 SecurityContext에 넣어줌
    2. API 응답
  • 인증 실패 시
    1. [BE] 유효하지 않은 토큰이므로 에러 반환
    2. [FE] Refresh Token으로 Access/Refresh Token Reissue API 요청
    3. [BE] 저장소에 해당 사용자의 Refresh Token이 있는지 확인
    4. [BE] Refresh Token이 없으면 만료된 사용자임을 알려주며 에러 반환 → [FE] 로그인 api 요청
    5. [BE] Refresh Token이 있으면 Access/Refresh Token 재생성 후 토큰 정보 반환

 

현재 프로젝트에서는 백엔드만 다루기 때문에 프론트 로직은 구현하지 않습니다.

여기서 프론트에서는 Access Token의 유효기간이 얼마 남지 않았을 경우, Token을 재발급하는 API를 요청하는 로직 정도를 추가하면 될 것 같습니다.

전체적인 인증의 흐름을 알아보기 위해 로그인/로그아웃로직보다 먼저 인증이 필요한 API를 호출할 때 로직을 구현해보겠습니다.

 


2️⃣ Security 설정

@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
@Configuration
public class WebSecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;
    private static final String[] ALLOWED_URIS = {"/api/auth/**", "/api/member/register", "/api/oauth2/**"};

    /* filterChain 설정 */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.cors().and()
		        .csrf().disable() // 토큰 사용 -> 비활성화
            .httpBasic().disable()
            .forLogin().disalbe()
            .sessionManagement()  //세션 사용 X
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
						.and()
            .authorizeRequests() // 인가처리
            .antMatchers(ALLOWED_URIS).permitAll()
            .antMatchers("/swagger-resources/**", "/swagger-ui/**", "swagger/**").permitAll()
            .anyRequest().authenticated()
						.and()
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .addFilterBefore(jwtExceptionFilter, JwtAuthFilter.class);

        return http.build();
    }

}

Security는 UsernamePasswordAuthenticationFilter 에서 인증된 사용자의 정보를 Authentication 객체로 만들어 SecurityContextHolder에 넣어줌으로써 인증 과정을 끝마칩니다.

이 과정을 직접 구현해야 하기 때문에, UsernamePasswordAuthenticationFilter 전에 JwtFilter가 작동하도록 등록한 뒤, 위의 과정을 대신 거치도록 구현할 것입니다.

 

이 때, 회원가입/로그인에 사용되는 API의 경우(ALLOWED_URIS) 인증된 사용자의 정보가 필요 없으므로 permitAll()로 허용해줍니다.

이 설정으로 허용된 URI에서는 인증된 사용자의 정보가 없더라도 security가 401 UnAuthorize를 반환하지 않고 정상 작동하게 됩니다.

 

⚠️주의

.permitAll()은 등록한 필터를 타지 않게해주는 것이 아닙니다.

단지 인증된 객체가 없어도 요청을 허용해준다는 의미입니다.

 


3️⃣ 인증 필터

✔️ Jwt Filter

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private static final String DELIMS = " ";
    private final JwtProvider jwtProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.info("JwtAuthFilter.doFilterInternal, Jwt 필터 인증 시작");
        String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);

        // 헤더에서 bearer 토큰인지 검증
        String accessToken = isBearerToken(authorization);

        if(StringUtils.hasText(accessToken) && jwtProvider.validateToken(accessToken)) {
            // Access Token 정보로 Authentication 객체 생성 및 저장
            Authentication authentication = jwtProvider.extractAuthentication(accessToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    private String isBearerToken(String authorization){
        return (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) ? authorization.split(DELIMS)[1].trim() : null;
    }

}

JWT Filter는 Access Token을 검증한 뒤, Access Token에서 추출한 사용자정보값으로 Security에서 요구하는 인증된 사용자 객체를 설정해주는 역할을 합니다.

순서는 다음과 같습니다.

  1. RequestHeader로 넘어온 Authorization의 토큰값을 검사합니다.
  2. 토큰이 bearer 타입이며 JWT 알고리즘에 의해 유효한 값인지 검증합니다.
  3. 유효성 검증을 통과하면, Access Token에서 추출한 사용자의 정보로 Authentication이라는 유저 정보를 생성하여 SecurityContextHolder에 넣어줍니다.

이렇게 SecurityContextHolder에 넣어준 Authentication은, 인증 후 로직(API)에서 사용자의 정보를 가져오고 권한을 검증하는 데 사용됩니다.

 

✔️ Jwt Provider

Jwt Filter에서 사용된 Jwt Provider는 JWT와 관련된 로직을 담당합니다.

JWT(access+refresh)를 생성, 검증, 정보추출 하는 모든 로직이 포함되어 있어 꽤 복잡합니다.

우선 **Jwt Filter에서만 사용되는 로직(access token 검증)**을 살펴보면 다음과 같습니다.

@Component
public class JwtTokenProvider {
	private final Key key; // 토큰을 암호화/복호화할 때 필요한 key
  private final MemberDetailsService memberDetailsService;
  private final RefreshTokenRepository refreshTokenRepository;

  public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey, 
											    MemberDetailsService memberDetailsService, 
											    RefreshTokenRepository refreshTokenRepository)
  {
      byte[] keyBytes = Decoders.BASE64.decode(secretKey);
      this.key = Keys.hmacShaKeyFor(keyBytes);
      this.memberDetailsService = memberDetailsService;
      this.refreshTokenRepository = refreshTokenRepository;
  }

  /* 토큰 유효성 검증 */
  public boolean validateToken(String token) {
      try {
          Jwts.parserBuilder()
                  .setSigningKey(key)
                  .build()
                  .parseClaimsJws(token);
          return true;
      } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
          throw new JwtException("유효하지 않은 토큰입니다.");
      } catch (ExpiredJwtException e){
          throw new JwtException("만료된 토큰입니다.");
      } catch (UnsupportedJwtException e){
          throw new JwtException("지원되지 않는 유형의 토큰입니다.");
      } catch (IllegalArgumentException e){
          throw new JwtException("클레임이 비어있습니다.");
      }
      return false;
  }

/* claim에 저장된 정보로 authentication 생성 */
  public Authentication extractAuthentication(String accessToken) {
      Claims claims = parseClaims(accessToken);
      // Authentication에 넘겨줄 Princiapal 생성
      UserDetails memberDetails = memberDetailsService.loadUserByUsername(claims.getSubject());
      return new UsernamePasswordAuthenticationToken(memberDetails, accessToken, memberDetails.getAuthorities());
  }

  /* 토큰 Parsing */
  private Claims parseClaims(String accessToken) {
      return Jwts.parserBuilder()
              .setSigningKey(key)
              .build()
              .parseClaimsJws(accessToken)
              .getBody();
  }
}

Authentication객체에 포함되는 정보는 Principal, Credentials, Authorities가 있습니다. ****Security는 이 값을 통해 사용자 정보를 알아옵니다.

  • Principal
    • 로그인된 사용자의 정보(아이디 등)를 알아올 수 있는 객체입니다. UserDetails라는 Security의 유저 객체가 해당됩니다.
  • Credentials
    • 비밀번호 등 민감한 정보를 포함할 수 있는데, 이후 동작하는 API에서도 접근할 수 있기 때문에 보안 상 민감한 정보는 포함하지 않는 것이 좋습니다.
  • Authorities
    • 유저의 권한을 넣어줄 수 있습니다. API에서 호출할 때 Controller에 @PreAuthorize 로 사용자 권한을 체크할 수 있습니다.
💡 Claims
JWT의 Claims는 토큰의 복호화값, 즉 사용자에 대한 정보가 들어있습니다. subject, claim, expiration 등의 설정이 들어있는 객체입니다.
💡 UsernamePasswordAuthenticationToken
Authentication의 구현체로서, 생성자에서 .setAuthenticated(true)가 실행되기 때문에 Security는 이 객체가 인증된 사용자의 객체임을 알 수 있습니다.

 

✔️ MemberDetailsService

Security의 UserDetailsService 구현체입니다.

사용자의 정보가 실제로 저장소에 존재하는 지 검증하는 단계입니다.

@RequiredArgsConstructor
@Service
public class MemberDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) {
        Member member = memberRepository.findByEmail(email)
                .orElseThrow(() -> new AuthException("존재하지 않는 계정입니다."));
        return new MemberDetails(member);
    }
}

여기서 UserDetails를 반환해주면 되는데, 저는 UseDetails를 커스텀하였기에 MemberDetails라는 구현체를 반환하였습니다.

 

✔️ MemberDetails (선택)

MemberDetails는 Security의 UserDetails를 구현한 커스텀 구현체입니다.

Authentication 객체의 Principal에 넣어줄 정보(아이디, 이메일, 비밀번호, 권한) 등을 커스텀할 수 있습니다.

@RequiredArgsConstructor
public class MemberDetails implements UserDetails {
    private final Member member;
    private static final String ROLE_PREFIX = "ROLE_";

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Stream.of(new SimpleGrantedAuthority(ROLE_PREFIX + member.getRole()))
                .collect(Collectors.toList());
    }

    public Long getMemberNo() {
        return member.getMemberNo();
    }

    @Override
    public String getPassword() {
        return null;
    }

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

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

이 때, boolean 메소드들이 true를 리턴해야 Security에게 빈 Authentication이 아니라 인증된 Authentication임을 알려줄 수 있습니다.

이제 인증 과정은 모두 끝났습니다.

 


3️⃣ 인증 과정을 거친 API에서 사용자 정보 가져오기

앞서 말했듯이, Security에서 인증된 사용자에 대한 정보는 SecurityContextHolder에 담긴 Athentication객체를 통해 가져올 수 있습니다.

로직에서 SecurityContextHolder를 호출해 정보를 가져올 수도 있지만, Security는 ArgumentResolver를 통해 Principal을 주입해줍니다.

따라서 Controller에서 간편하게 Principal을 파라미터로 받으면 정보를 가져올 수 있습니다.

💡 참고로 저는 UserDetails 대신 MemberDetails를 Principal로 넘겼습니다.

✔️ Principal

Principal 인터페이스를 ****그대로 파라미터로 받아올 수 있습니다.

public ...(**Principal** principal) {
	String userEmail = principal.getName();
}

✔️ @AuthenticationPrincipal

어노테이션을 사용해 Principal로 설정해준 클래스(예:UserDetails)를 파라미터로 받아올 수 있습니다.

public ... (**@AuthenticationPrincipal MemberDetails** memberDetails) {
	String userEmail = memberDetails.getUsername();
    int userNo = memberDetails.getUserNo();
    String userAuthorities = memberDetails.getAuthorities().toString();
}

 


✅ 요약

Security Filter Chain에서 어떤 주소에 인증된 정보가 필요한지 설정해줍니다.

이후 인증이 필요한 API 주소를 요청할 때, JWT Filter를 거치면서 Request Header의 Authorization값에 담긴 Access Token을 검증합니다.

 

검증 성공 시

  • Access Token을 바탕으로 사용자 정보 (아이디, 권한 등)을 추출해 인증된 Authentication 객체를 생성합니다.
  • 이 객체를 SecurityContextHolder에 넣어주면 JWT Filter의 역할은 끝납니다.
  • 이후 원래 호출한 API에서는 저장된 Authentication 객체를 바탕으로 사용자 정보를 알아올 수 있습니다.

검증 실패 시

  • 예외를 던져 API 요청을 중단시킵니다.