본문 바로가기

카테고리 없음

GraphQL 스프링 시큐리티 적용하기

Project setup

시작하기 위해서는 웹 및 Security starters가 프로젝트 내에 모두 필요합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

또한 GraphQL Spring 부팅 스타터를 사용하여 Spring 부팅을 사용하는 GraphQL API를 만들 예정입니다.

 

<dependency>
    <groupId>com.graphql-java-kickstart</groupId>
    <artifactId>graphql-spring-boot-starter</artifactId>
    <version>5.10.0</version>
</dependency>

API 내에서 인증하려면 JSON 웹 토큰(JWT)을 사용해야 하므로 JWT 라이브러리도 추가했습니다.

 

<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>3.8.3</version>
</dependency>

준비가 끝났습니다.

 

올바른 알고리즘 선택

첫 번째 단계는 보안 구성 내에서 사용할 bean을 만드는 것입니다. JWT를 사용하기 때문에 토큰을 보호하기 위해 사용할 알고리즘도 지정해야 합니다. 이 예에서는 HMAC256을 사용하므로 다음 bean을 정의했습니다.

 

@Bean
public Algorithm jwtAlgorithm() {
    return Algorithm.HMAC256("my-JWT-secret");
}

보시다시피, 제 경우에는 "my-JWT-secret"가 될 비밀 키도 지정해야 합니다. 실제로는 더 강력한 비밀을 원할 수 있으며 이를 별도의 속성 파일로 이동할 수 있습니다. 다음 단계는 JWTVerifier를 설정하는 것입니다. 이 클래스는 토큰이 올바른 알고리즘과 비밀로 서명되었는지 확인하고 페이로드가 예상과 일치하는지 확인하는 데 사용할 수 있습니다(예: 만료 날짜 및 발급자가 올바른지 등). 제 경우에는 방금 정의한 알고리즘을 사용하고 발급자가 내 API인지 확인합니다.

 

@Bean
public JWTVerifier verifier(Algorithm algorithm) {
    return JWT
        .require(algorithm)
        .withIssuer("my-graphql-api")
        .build();
}

내가 만들 마지막 bean은 AuthenticationProvider입니다. 정의 방법은 사용자 자격 증명(LDAP, 데이터베이스, 메모리 등)을 저장하는 위치에 따라 다릅니다. 제 경우에는 데이터베이스를 사용하여 자격 증명을 저장하겠습니다.

 

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(10);
}


@Bean
public AuthenticationProvider authenticationProvider(UserService userService, PasswordEncoder passwordEncoder) {
    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setUserDetailsService(userService);
    provider.setPasswordEncoder(passwordEncoder);
    return provider;
}

다른 튜토리얼을 위한 자료이고 GraphQL과 관련이 없기 때문에 자세히 설명하지 않겠습니다.

 

요청 필터 설정

사용자가 인증하는 방법은 GraphQL API 내에서 특정 변형을 호출하여 사용자가 전달한 자격 증명을 확인하고 적절한 JSON 웹 토큰을 반환하는 것입니다. 이 토큰은 Authorization: Bearer <token>과 같은 헤더로 전달됩니다.

 

토큰이 유효한지 확인하기 위해 사용자 지정 필터를 사용하겠습니다.

 

@Component
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {
    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final Pattern BEARER_PATTERN = Pattern.compile("^Bearer (.+?)$");
    private final UserService userService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        getToken(request)
            .map(userService::loadUserByToken)
            .map(userDetails -> JWTPreAuthenticationToken
                .builder()
                .principal(userDetails)
                .details(new WebAuthenticationDetailsSource().buildDetails(request))
                .build())
            .ifPresent(authentication -> SecurityContextHolder.getContext().setAuthentication(authentication));
        filterChain.doFilter(request, response);

    }

    private Optional<String> getToken(HttpServletRequest request) {
        return Optional
            .ofNullable(request.getHeader(AUTHORIZATION_HEADER))
            .filter(not(String::isEmpty))
            .map(BEARER_PATTERN::matcher)
            .filter(Matcher::find)
            .map(matcher -> matcher.group(1));
    }

이 필터는 헤더에서 토큰을 구문 분석하고 WebAuthenticationDetailsSource를 사용하여 적절한 인증을 설정합니다. 그런 다음 이 개체는 SecurityContext 내에 저장된 PreAuthenticatedAuthenticationToken 클래스 내에 래핑됩니다. 이를 위해 PreAuthenticatedAuthenticationToken의 사용자 지정 구현을 만들었습니다.

 

@Getter
public class JWTPreAuthenticationToken extends PreAuthenticatedAuthenticationToken {

    @Builder
    public JWTPreAuthenticationToken(JWTUserDetails principal, WebAuthenticationDetails details) {
        super(principal, null, principal.getAuthorities());
        super.setDetails(details);
    }

    @Override
    public Object getCredentials() {
        return null;
    }
}

userService.loadByToken()이 작동하는 방식은 앞에서 정의한 JWTVerifier를 사용하여 토큰을 확인하는 것입니다.

 

private Optional<DecodedJWT> getDecodedToken(String token) {
    try {
        return Optional.of(verifier.verify(token));
    } catch(JWTVerificationException ex) {
        return Optional.empty();
    }
}

그런 다음 사용자 정보를 가져오고 UserDetails 개체를 설정할 수 있습니다.

 

@Transactional
public JWTUserDetails loadUserByToken(String token) {
    return getDecodedToken(token)
        .map(DecodedJWT::getSubject)
        .flatMap(repository::findByEmail)
        .map(user -> getUserDetails(user, token))
        .orElseThrow(BadTokenException::new);
}

그러나 앞서 언급했듯이 이것은 사용자가 어디에 저장되어 있는지에 따라 크게 달라집니다. 사용자 지정 클레임으로 필요한 모든 세부 정보를 토큰에 넣고 토큰 자체에서 읽을 수도 있습니다.

 

필터를 설정했으면 체인에 필터를 추가하도록 Spring Security를 ​​설정할 수 있습니다.

 

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
@EnableConfigurationProperties(SecurityProperties.class)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    private final AuthenticationProvider authenticationProvider;
    private final JWTFilter jwtFilter;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        auth.authenticationProvider(authenticationProvider);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
            .anyRequest().permitAll()
            .and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
            .addFilterBefore(jwtFilter, RequestHeaderAuthenticationFilter.class); // Filter
    }
}

필터를 추가하는 것 외에도 이전에 정의한 AuthenticationProvider 빈도 전달합니다. 또한 GraphQL은 단일 엔드포인트만 노출하고 일부 쿼리에는 인증이 필요하고 일부 쿼리는 필요하지 않기 때문에 모든 것을 허용하도록 Spring Security를 ​​구성했습니다.

 

로그인

사람들이 사용자 이름과 비밀번호를 사용하여 로그인할 수 있도록 사용자 지정 mutation을 정의했습니다.

 

type Mutation {
    login(email: String!, password: String!): User
}

type User {
    token: String
    person: Person
}

이것은 또한 로그인을 처리하기 위해 사용자 정의 MutationResolver를 정의해야 함을 의미합니다.

@Component
@RequiredArgsConstructor
public class MutationResolver implements GraphQLMutationResolver {
    private final UserService userService;
    private final AuthenticationProvider authenticationProvider;

    @PreAuthorize("isAnonymous()")
    public User login(String email, String password) {
        UsernamePasswordAuthenticationToken credentials = new UsernamePasswordAuthenticationToken(email, password);
        try {
            SecurityContextHolder.getContext().setAuthentication(authenticationProvider.authenticate(credentials));
            return userService.getCurrentUser();
        } catch (AuthenticationException ex) {
            throw new BadCredentialsException(email);
        }
    }
}

이 뮤테이션 리졸버에서 사용자가 올바른 사용자 이름과 비밀번호를 전달했는지 확인하기 위해 다시 AuthenticationProvider를 사용하고 있습니다. 아직 로그인하지 않았을 때만 이 작업을 수행할 수 있어야 하므로 이 메서드에 @PreAuthorize("isAnonymous()") 주석을 추가했습니다.

 

Spring Security 부분을 설정하는 것 외에도 사용자 정의 User 객체도 반환합니다. 이것은 우리가 API 소비자에게 노출할 것이며 이름, 프로필 사진 등과 같은 현재 사용자에 대한 유용한 정보를 포함할 수 있습니다.

 

사용자에 대한 토큰을 반환하는 사용자 지정 확인자를 추가합니다.

 

@Component
@RequiredArgsConstructor
public class UserResolver implements GraphQLResolver<User> {
    private final UserService service;

    @PreAuthorize("isAuthenticated()")
    public String getToken(User user) {
        return service.getToken(user);
    }
}
이제 다음과 같은 토큰을 생성할 수 있습니다.
@Transactional
public String getToken(User user) {
    Instant now = Instant.now();
    Instant expiry = Instant.now().plus(Duration.ofHours(2)); // Token will be valid for 2 hours
    return JWT
        .create()
        .withIssuer("my-graphql-api") // Same as within the JWTVerifier
        .withIssuedAt(Date.from(now))
        .withExpiresAt(Date.from(expiry))
        .withSubject(user.getEmail())
        .sign(algorithm); // Same algorithm as within the JWTVerifier
}

이를 통해 login-mutation을 호출하여 성공적으로 로그인할 수 있으며 토큰을 Authorization 헤더로 전달하여 추가 API 호출에 대해 스스로를 인증할 수 있습니다.

 

Authorization(권한 부여)

이전에 보았듯이 모든 사람이 GraphQL API를 호출할 수 있도록 Spring Security를 ​​구성했습니다. 특정 작업에 대한 권한 부여를 요구하려면 이전과 같이 @PreAuthorize 주석을 사용할 수 있습니다.

 

예를 들어 updatePassword 작업이 있다고 가정해 보겠습니다. 이 작업은 인증된 사람에게만 허용되어야 하므로 다음과 같이 리졸버에 주석을 달 수 있습니다.

@PreAuthorize("isAuthenticated()")
public User updatePassword(UpdatePasswordInput input) {
    return userService.updatePassword(userService.getCurrentUser().getPersonId(), input);
}

또한 작업에 특정 역할이 필요한 경우 다음과 같이 할 수 있습니다.

@PreAuthorize("hasAuthority('ADMIN')")
public StudyMaterial approveStudyMaterial(long studyMaterialId) {
    return studyMaterialService.approve(studyMaterialId, true);
}

이 경우 관리자만 특정 사항을 승인할 수 있으므로 hasAuthority() 메서드를 사용하여 이를 확인합니다.

 

이 접근 방식의 주요 단점은 스키마가 여전히 공개되어 있고 스키마 자체를 필터링할 방법을 찾지 못했다는 것입니다. 이는 스키마를 체크아웃하는 사람들이 액세스할 수 없는 경우에도 특정 작업이 있음을 알 수 있음을 의미합니다.

 

또한 GraphiQL은 인터페이스 자체 내에서 HTTP 헤더를 설정하는 것을 허용하지 않습니다. 다음과 같이 애플리케이션 속성 내에서 헤더를 구성할 수 있습니다.

graphiql:
  headers:
    Authorization: Bearer my-generated-token

인터페이스 내에서 헤더를 설정하려면 GraphQL Playground 또는 Postman과 같은 다른 GraphQL 클라이언트에 의존해야 합니다.

이제 Spring으로 생성한 GraphQL API에 보안을 완전히 추가할 수 있습니다.