Spring Security와 JWT로 로그인 구현하기

구환준/모건·2023년 11월 18일

UMC-5th

목록 보기
8/10

개념과 키워드

인증과 인가

  • 인증(Authentication): 사용자가 본인이 맞는 지 확인하는 절차
  • 인가(Authorization): 인증된 사용자가 요청한 자원에 접근 가능한 지 결정하는 절차
  • 인증을 받으면→ 권한을 인가하여 얻게 됨
  • 매우 중요) Spring Security는 Principal(username) - Credential(password) 패턴을 사용한다
  • 매우 중요2) Spring Security는 기본적으로 세션을 사용한다

Spring Security

특징

  • Filter 기반이다 - Spring MVC와 분리됨
  • Bean으로 설정할 수 있다(3.2버전 이후)

흐름

  1. Http Request를 수신: 사용자가 로그인 정보(아이디, 비번)와 함께 인증 요청
  2. AuthenticationFilter가 요청을 가로채 UsernamePasswordAuthenticationToken의 인증 객체 생성
  3. Filter에서 AuthenticationToken을 AuthenticationManager의 구현체로 위임
  4. AuthenticationManager는 등록된 AuthenticationProvider를 조회하여 인증을 요구
  5. DB에서 사용자 인증정보를 가져오는 UserDetailsService에 로그인 정보를 넘겨줌(우리 코드 기준, Member와 User를 매핑 가능)
  6. 넘겨받은 정보를 기반으로 DB에서 찾아 UserDetails객체를 생성
  7. UserDetails를 다시 AuthenticationProvider로 넘겨 비교
  8. 인증 객체(Authentication) or Exception 반환
  9. AuthenticationFIlter에 객체 전달
  10. 객체를 Security Context에 저장

Filter에도 종류가 많지만(Rememberme, Logout 등) 필요할 때 찾아보면 됨

JWT

Json Web Token이란 뜻으로, ‘JSON 객체에 인증 객체(토큰)을 담아 보낸다’ 라고 이해하면 된다

쿠키와 세션을 이용한 로그인의 문제점을 보완한 형태

쉽게 말하면 쿠키는 사용자 정보를 기록하는 작은 저장소이고(아이디 비번 저장, 오늘 다시 보지 않기), 세션은 쿠키를 이용해 사용자 정보에 이름을 붙여 저장하는 것

구성

Payload부분에 회원의 정보가 담기고, 정보 속 하나의 조각을 Claim이라 한다

로그인에 성공하면 서버는 회원의 정보가 담긴 JWT를 반환하고 (Response)

이후 클라이언트는 권한이 필요한(로그인이 필요한) 요청이 있을 때마다 요청 헤더에 JWT를 담아서 보내야 한다

Access Token, Refresh Token

JWT가 뭔지는 대충 알 것 같은데 이건 또 뭐지?

개발 단계에서는 주로 토큰의 유효기간에 제한을 안 두지만, 실제 서비스에선 절!대! 그렇게 하면 안된다.

위에서 언급했듯, JWT는 세션 기반이 아니기 때문에 한도 무제한의 토큰을 주인이 아닌 누군가에게 털린다면, 서버 입장에선 그 사실을 알 수가 없다… 그래서 등장한 것이 Access와 Refresh 토큰이다

  • Access Token: 말 그대로 리소스에 접근하기 위한 토큰. 짧은 유효기간을 가지며, 사용자가 주로 사용하는 토큰
  • Refresh Token: Access Token 대비 긴 시간을 가지며, 사용자 정보를 확인하여 Access Token을 재발급하기 위한 토큰

물론 이 방식도 완벽하진 않다(둘 다 털리면;;)

HS512 알고리즘

HMAC-SHA-512 (Hash-based Message Authentication Code with Secure Hash Algorithm 512) 알고리즘의 한 종류

주로 데이터의 무결성과 인증을 보장하기 위해 사용

한마디로 비밀번호 인코딩(해싱)에 사용된다고 이해하면 됨

실습

이제 천 천 히 실제 코드로 작성해보자

이전에 만든 게시판 프로젝트에 로그인을 주입해볼 예정이다

기본 세팅

  • build.gradle에 의존성 주입하기
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-security'// 스프링 시큐리티
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test' // 스프링 시큐리티 테스트
	runtimeOnly 'com.h2database:h2'
	// JWT
	implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
	runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
}
  • application.properties에 시크릿 키 넣기
# JWT 시크릿 키 설정
jwt.secret.key = 1bHO0iXdu2GfRE1Lj0GMSTrcv5U2Xqy0+VDViviWOh4QS9t1q69NOWD20nZO5/o6UyKUKS7w8pkpygfN9XF1vg==

위에 거 그냥 복사해도 됨(원래는 자기가 정한 64비트 길이 키)

랜덤 키 생성기 이용하면

Member에 속성 추가하기

이메일, 비번 그리고 권한을 뜻하는 Authority 추가

public enum Authority {
    ROLE_USER, ROLE_ADMIN
}

enum 타입으로 생성한 후,

			@Column(name = "EMAIL")
	    private String email;
	    @Column(name = "PASSWORD")
	    private String password;
	    @Enumerated(EnumType.STRING)
	    private Authority authority;

이정도만 추가

꿀팁) 처음에 Member라고 이름 지은 이유가 쿼리 예약어 때문도 있지만 시큐리티에서의 UserDetails 관련 코딩이 수월해짐!

MemberRepository에 메서드 추가하기

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByEmail(String email); //이메일로 Member 찾기
    boolean existsByEmail(String email); //이메일로 Member 존재 여부 확인
}

클래스 설명

  • JWT 관련
    • TokenProvider: 유저 정보로 JWT 토큰을 만들거나 토큰을 바탕으로 유저 정보를 가져옴
    • JwtFilter: Spring Request 앞단에 붙일 Custom Filter
  • Spring Security 관련
    • JwtSecurityConfig: JWT Filter를 추가
    • JwtAccessDeniedHandler: 접근 권한 없을 때 403 에러
    • JwtAuthenticationEntryPoint: 인증 정보 없을 때 401 에러
    • SecurityConfig: 스프링 시큐리티에 필요한 설정
    • SecurityUtil: SecurityContext에서 전역으로 유저 정보를 제공하는 유틸 클래스
    • CorsConfig: 서로 다른 Server 환경에서 자원을 공유에 필요한 설정 CorsConfig 클래스는 Cross-Origin Resource Sharing (CORS)를 관리하고 구성하는 데 사용되는 클래스
      CORS는 다른 도메인 또는 서버로부터 리소스를 요청하는 웹 애플리케이션 간의 보안 정책을 관리하기 위한 메커니즘
      웹 애플리케이션이 다른 도메인의 리소스를 요청할 때 브라우저에서 보안 제약을 적용하며, 이를 해결하기 위해 CORS 설정이 필요합니다. CorsConfig 클래스의 역할:
      1. 서로 다른 도메인 또는 서버로부터의 HTTP 요청을 수락 또는 거부할 CORS 규칙을 구성

      2. 특정 경로 또는 URL에 대한 CORS 정책을 설정하여, 해당 경로로의 요청에 대한 제어

      3. CORS 관련 HTTP 헤더를 응답에 포함하여 브라우저가 해당 요청을 허용

        예를 들어, 다른 도메인의 웹 애플리케이션이 스프링 기반의 API를 호출하려고 할 때, 스프링 애플리케이션에서는 CORS 설정을 사용하여 이 요청을 허용하거나 거부할 수 있다.

TokenDto

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenDto {
    private String grantType;
    private String accessToken;
    private Long accessTokenExpiresIn;
    private String refreshToken;

}

RefreshToken

@Builder
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class RefreshToken {
    @Id
    @Column(name = "RT_KEY")
    private String key; //member id 들어감

    @Column(name = "RT_VALUE")
    private String value;// token값 들어감

    public RefreshToken updateValue(String token) {
        this.value = token;
        return this;
    }
}

TokenProvider

@Slf4j
@Component
public class TokenProvider {
    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "Bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 유효기간 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 유효기간 7일

    private final Key key;

    public TokenProvider(@Value("${jwt.secret.key}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    public TokenDto generateTokenDto(Authentication authentication) {
        // 권한들 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())       // payload "sub": "name"
                .claim(AUTHORITIES_KEY, authorities)        // payload "auth": "ROLE_USER"
                .setExpiration(accessTokenExpiresIn)        // payload "exp": 151621022 (ex)
                .signWith(key, SignatureAlgorithm.HS512)    // header "alg": "HS512"
                .compact();

        // Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
                .signWith(key, SignatureAlgorithm.HS512)
                .compact();

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }

    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        // 클레임에서 권한 정보 가져오기
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        // UserDetails 객체를 만들어서 Authentication 리턴
        UserDetails principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) { //토큰 검증
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            log.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            log.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            log.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}
  • SimpleGrantedAuthority 있는 부분 빨간줄이 해결이 안된다면 위에서
    import org.springframework.security.core.authority.SimpleGrantedAuthority;
  1. TokenProvider 생성자:
    • @Value("${jwt.secret.key}")를 통해 설정에서 읽어와서 Keys.hmacShaKeyFor()를 사용하여 jwt를 사용할 키 생성
  2. generateTokenDto(Authentication authentication) 메서드:
    • Authentication 객체를 받아서 JWT 토큰을 생성하고, 이를 TokenDto 객체로 래핑하여 반환
    • Authentication 객체는 현재 사용자의 인증 및 권한 정보를 포함
    • Access Token과 Refresh Token을 생성하며, Access Token은 짧은 유효 기간(30분)을 가지고, Refresh Token은 더 긴 유효 기간(7일)
    • 생성된 토큰은 클라이언트에게 반환
  3. getAuthentication(String accessToken) 메서드:
    • Access Token을 받아서 해당 토큰에 대한 Authentication 객체를 생성+반환
    • 토큰을 복호화하고, 토큰에 저장된 권한 정보를 추출하여 UserDetails 객체를 생성
    • UserDetails 객체를 사용하여 UsernamePasswordAuthenticationToken을 생성+반환
  4. validateToken(String token) 메서드:
    • 주어진 토큰의 유효성을 검사 토큰이 유효한 경우 true, 그렇지 않으면 false
    • 유효성 검사 중 발생하는 다양한 예외를 처리하고, 토큰이 만료되었거나 잘못된 경우 해당 로그를 기록
  5. parseClaims(String accessToken) 메서드:
    • Access Token을 받아서 해당 토큰의 클레임(claims) 정보를 추출하여 파싱
    • 만료된 토큰의 경우에도 클레임 정보를 반환

JwtFilter

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {

    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";
    private final TokenProvider tokenProvider;

    // 실제 필터링 로직은 doFilterInternal 에 들어감
    // JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 1. Request Header 에서 토큰을 꺼낸다
        String jwt = resolveToken(request);

        // 2. validateToken 으로 토큰 유효성 검사
        // 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    // Request Header 에서 토큰 정보를 꺼내오는 메서드
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.split(" ")[1].trim();
        }
        return null;
    }
}
  1. doFilterInternal 메서드:
    • 실제 필터링 로직이 구현된 메서드
    • 클라이언트의 요청이 들어오면 이 메서드가 호출됨
    • 먼저, 요청 헤더에서 JWT 토큰을 추출
  2. resolveToken 메서드:
    • 이 메서드는 요청 헤더에서 JWT 토큰 정보를 추출
    • Authorization 헤더에서 "Bearer " 접두사를 가진 토큰을 추출
    • 추출한 토큰을 반환하거나, 토큰이 없는 경우 null
  3. JWT 토큰 유효성 검사:
    • 추출한 JWT 토큰이 있고, tokenProvider.validateToken(jwt) 메서드로 토큰의 유효성 검사
    • 만약 토큰이 유효하다면, tokenProvider.getAuthentication(jwt)를 호출하여 해당 토큰에서 Authentication 객체(사용자 인증 및 권한 정보)를 가져옴
  4. SecurityContextHolder에 인증 정보 저장:
    • 가져온 Authentication 객체를 현재 쓰레드의 SecurityContext에 저장
    • 이를 통해 현재 사용자의 인증 및 권한 정보를 애플리케이션에서 사용할 수 있게 됨
  5. filterChain.doFilter(request, response) 호출:
    • 마지막으로, 다음 필터로 요청을 전달하기 위해 filterChain.doFilter(request, response)를 호출
    • 이렇게 하면 다음 필터나 요청 핸들러로 요청이 이동

다음 시간엔 본격적으로 Spring Security와 관련된 Config 코드와 실제 동작까지 해보자!

0개의 댓글