#3. Security + JWT 적용 (1)

달래·2023년 12월 10일
0

개인프로젝트

목록 보기
3/6

앞선 시리즈에서 언급했듯이, 이 프로젝트에서는 인증/인가 과정에 Spring Security와 JWT(access + refresh)를 적용하여 백엔드를 구현하기로 하였다.

🛠️ 환경
spring boot 2.7 + jpa + redis + mysql

0. 인증 플로우


  1. 인증이 필요 없는 로그인/회원가입

    1. 회원가입
      1) 회원가입 api 요청
      2) 회원가입 로직
      3) 201 Created 상태코드 반환
    2. 로그인
      1) 로그인 api 요청
      2) 아이디/패스워드 검증
      3) access/refresh 토큰 생성 + refresh 토큰 저장
      4) 토큰 정보 반환
  2. 인증이 필요한 api 호출 - api 호출 전에 jwt필터에서 access Token 유효성 검증

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

여기서 프론트에서는 access 토큰의 유효기간이 얼마 남지 않았을 경우 reissue를 요청하는 로직 정도가 추가 가능한 것 같다.

우선 이 (1)편에서는 로그인/회원가입이 아닌 인증이 필요한 api에 대한 사용자 인증처리를 알아보도록 하자!

1. 설정


Spring Security Config

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

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

    /* filterChain 설정 */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf().disable() // 토큰 사용 -> 비활성화
            .and()
                .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);

        return http.build();
    }

}

회원가입/로그인의 경우 인증된 사용자의 정보가 필요 없으므로 permitAll()로 허용해준다.
이렇게 하면, 허용된 URI에서는 인증된 사용자의 정보가 없더라도 security가 401 UnAuthorize를 반환하지 않고 정상 작동하게 된다.

⚠️주의
.permitAll()이 등록한 필터를 타지 않게해주는 것이 아니다.
단지 인증된 객체가 없어도 요청을 허용해준다는 의미이다.


2. 인증


1) Jwt Filter

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

    private static final String DELIMS = " ";
    private final JwtTokenProvider jwtTokenProvider;

    @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) && jwtTokenProvider.validateToken(accessToken)) {
            // access 토큰 정보로 Authentication 객체 생성 및 저장
            Authentication authentication = jwtTokenProvider.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을 통해 사용자를 인증한다.

주 로직은 다음과 같다.

  1. 요청에서 넘어온 Authorization헤더의 토큰값을 검사한다.
  2. 토큰이 bearer 타입이고 유효한지 검증한다.
  3. 모두 통과시, access 토큰 정보로 jwt provider에서 Authentication이라는 유저 정보를 생성하여 SecurityContextHolder에 넣어준다.

이렇게 SecurityContextHolder에 넣어준 Authentication은,
다른 api에서 로그인된(인증된) 사용자의 정보를 가져오거나 권한을 검증하는 데 사용된다!

2) Jwt Provider

jwt provider에는 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가 있다.
Principal은 로그인된 사용자의 정보(아이디 등)를 알아올 수 있는 객체이다. UserDetails라는 security의 유저 객체가 해당된다.
Credentials의 경우 비밀번호 등이 포함될 수 있는데, 보안 상 비밀번호를 포함하는 것은 추천하지 않는다.
Authorities는 유저의 권한을 넣어 줄 수 있는데, api에서 바로 @PreAuthorize 등으로 간편하게 권한을 체크할 수 있다.

여기에서 UsernamePasswordAuthenticationToken을 생성하면, 생성 할 때 .setAuthenticated(true)가 실행되어 security는 이 객체가 인증된 사용자의 객체임을 알 수 있다.

3) MemberDetails

MemberDetails는 Security의 UserDetails를 구현한 커스텀 구현체이다.
여기서 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임을 알려줄 수 있다.

4) MemberDetailsService

security의 UserDetailsService를 구현한 구현체이다.
이것을 구현해주어야 security는 db에서 값을 가져왔다고 인식하는 듯 하다..ㅎㅎ
여기서 계정 검증을 해준다. (비밀번호 검증이 아니다. 비밀번호 검증은 로그인시 해준다.)

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

여기서 넘겨준 값을 Authentication의 Principal로 넘겨주었기 때문에, UserDetails를 커스텀한 MemberDetails를 반환해준다.

3. 인가 - 다른 api에서 로그인된 사용자 정보를 어떻게 가져올까? 🤨


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

Security가 자동으로 주입해주기 때문에 Authentication 객체를 로직단에서 바로 받아올 수도 있지만, 다른 방법도 있다.

참고) 나는 앞에서 MemberDetails를 Principal로 넘겼다.

return new UsernamePasswordAuthenticationToken(memberDetails, accessToken, memberDetails.getAuthorities());

1) Principal

Authentication 객체로 넘긴 Principal의 getUsername() 메소드 값을 가져온다.
즉, 여기에서는 MemberDetails의 getUsername()을 가져온다.

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

2) @AuthenticationPrincipal

Authentication 객체로 넘긴 Principal 자체를 가져온다.

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

인증과정 요약 😇


여기까지, 로그인된 사용자만 접근 가능한 api의 요청의 인증 플로우를 알아보았다.

짧게 요약하자면,
jwt토큰의 claims에 담겨있는 사용자에 대한 정보를 통해, SecurityContextHolder에 Authentication 객체를 설정해주면 api에서는 로그인된 사용자의 정보를 알아올 수 있다.

다음은 로그인/회원가입의 로직에 대해서 알아보자!!

profile
아좌잣~!

0개의 댓글