관리 메뉴

거니의 velog

(27) 스프링 시큐리티와 API 서버 1 본문

SpringBoot_React 풀스택 프로젝트

(27) 스프링 시큐리티와 API 서버 1

Unlimited00 2024. 3. 5. 18:23

* API 서버는 기본적으로 지난번 호출에 대해 기록하지 않는 무상태(stateless) 서비스이다. 

무상태(stateless) 서비스란 서버가 클라이언트의 이전 요청에 대한 정보를 기억하지 않고, 각각의 요청을 독립적으로 처리하는 특성을 말합니다. 
다시 말해, 서버는 각각의 클라이언트 요청을 별개의 이벤트로 취급하며, 이전 요청의 상태나 정보를 저장하지 않습니다.

무상태 서비스의 특징은 다음과 같습니다.

1. 상태 저장 없음(Stateless): 서버는 클라이언트의 상태를 저장하지 않습니다. 클라이언트의 각 요청은 독립적이며, 이전 요청의 상태에 영향을 받지 않습니다. 이는 서버에서 세션 정보나 쿠키를 사용하지 않는다는 것을 의미합니다.

2. 성능 향상: 무상태 서비스는 서버 측에서 클라이언트의 상태를 관리하지 않으므로 서버는 더 많은 클라이언트 요청을 효과적으로 처리할 수 있습니다. 또한, 클라이언트의 상태를 전송할 필요가 없기 때문에 네트워크 부하가 감소하고 응답 시간이 개선될 수 있습니다.

3. 확장성: 무상태 서비스는 쉽게 확장할 수 있습니다. 새로운 서버 인스턴스를 추가하면 새로운 요청을 분산시킬 수 있고, 이는 서비스의 전반적인 확장성을 높입니다.

4. 클라이언트-서버 독립성: 클라이언트와 서버 간의 독립성이 높아집니다. 클라이언트는 서버와의 통신을 위해 모든 필요한 정보를 제공하며, 서버는 이 정보를 기반으로 동작합니다.

무상태 서비스는 RESTful API에서 주로 사용되며, 이러한 특징은 서비스의 단순성, 확장성, 성능 향상 등을 도모합니다. 
클라이언트는 각 요청에서 필요한 모든 정보를 제공하며, 서버는 각각의 요청을 독립적으로 처리하여 서비스의 유연성과 효율성을 높이는 것이 목표입니다.

* 그러므로 사용자를 기억하고 인증하기 위해서는 서버에서 인증 관련 데이터를 보관하는 대신, 프론트엔드 쪽에서 사용자 인증과 관련된 정보를 유지한다.

* 앱이나 웹 애플리케이션에서 유지하는 사용자 정보는 API 서버를 호출할 때 주고받아야 하기 때문에 이에 대한 보안상의 문제를 고려해야 한다. 예제에서는 JWT(JSON Web Token)을 활용해서 인증 정보를 리액트에서 보관하고 서버 호출 시에 사용할 수 있도록 구성한다.


**JSON Web Token (JWT)**은 웹에서 정보를 안전하게 전송하기 위한 토큰 형식의 개방형 표준(Open Standard)입니다. JWT는 페이로드에 정보를 저장하고, 서명(signature)을 사용하여 토큰이 변경되지 않았음을 검증합니다. 일반적으로 사용자의 인증 및 권한 부여에 자주 활용됩니다.
JWT는 다음과 같은 세 부분으로 이루어져 있습니다:

  1. Header: JWT의 헤더는 두 가지 부분으로 구성되어 있습니다. 첫 번째 부분은 토큰의 종류를 나타내며, 여기서는 "JWT"라고 지정합니다. 두 번째 부분은 사용된 해시 알고리즘을 지정합니다.

    {
      "alg": "HS256",
      "typ": "JWT"
    }

  2. Payload: 실제로 전송하려는 데이터가 포함되는 부분입니다. 클레임(Claim)이라고도 불리며, 세 가지 유형이 있습니다.
    • Registered claims: 특정한 의미를 가지고 있는 클레임으로, 토큰의 사용을 규정합니다. 예로는 iss (발급자), exp (만료 시간), sub (주제), aud (청취자) 등이 있습니다.
    • Public claims: 충돌을 방지하기 위해 생성될 때에는 공개적으로 사용할 수 있는 클레임입니다.
    • Private claims: 사용자나 개발자 등이 정의한 클레임으로, 충돌을 방지하기 위해 이름을 URI 형식으로 작성해야 합니다.

      {
        "sub": "1234567890",
        "name": "John Doe",
        "admin": true
      }
  3. Signature: 헤더와 페이로드의 인코딩 값을 비밀 키와 함께 서명하여 생성됩니다. 이 서명을 통해 토큰이 변경되지 않았음을 검증할 수 있습니다.

    HMACSHA256(
      base64UrlEncode(header) + "." +
      base64UrlEncode(payload),
      secret
    )

JWT의 중요한 특징은 서버에서 생성된 토큰이기 때문에 서버에서 검증되어야 한다는 것입니다. 또한, 토큰 자체에는 내부 상태를 저장하지 않기 때문에 한 번 발급된 토큰은 해당 정보가 변경되지 않는 한 계속해서 사용됩니다. 이러한 특성은 서버가 사용자 상태를 저장하지 않고도 사용자를 식별하고 권한을 부여하는 데에 유용하게 활용됩니다.


* 이번 장의 개발 목표는 다음과 같다.

1. API 서버를 위한 스프링 시큐리티의 설정

2. API 서버 호출과 JWT 문자열 생성

3. JWT 생성과 검증

4. Access Token과 Refresh Token을 이용한 토큰 갱신

1. 스프링 시큐리티의 설정

* 회원 데이터는 API 서버와 JSON 포맷으로 주고 받도록 구성하고 최근 아이디 대신 이메일을 사용하는 경우가 늘고 있으므로 이메일과 닉네임, 패스워드, 회원 권한, 소셜 회원 여부 등으로 구성된다. 스프링 시큐리티의 기능을 추가해서 로그인 처리를 구현한다.

* 프로젝트 내에 build.gradle 파일에 스프링 시큐리티 관련 모듈을 추가한다.

dependencies {
	(...)
	implementation 'org.springframework.boot:spring-boot-starter-security'
}

* 스프링 시큐리티와 관련된 설정을 위해서 프로젝트의 config 패키지에 CustomSecurityConfig.java 파일을 추가한다.

package com.unlimited.mallapi.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Configuration
@Log4j2
@RequiredArgsConstructor
public class CustomSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        log.info("-----------------security-----------------");
        return http.build();
    }

}

* 시큐리티와 관련된 로그를 자세히 확인하기 위해서 application.properties에 로그 설정을 추가한다.

# 스프링 시큐리티 로그 레벨 설정
logging.level.org.springframework.security.web=trace

* 변경된 설정을 확인하기 위해서 프로젝트를 실행하면 시큐리티에서 사용하는 임시 패스워드가 발생하는 것을 볼 수 있다.


(1) API 서버를 위한 기본 설정

* API 서버는 외부에서 Ajax로 호출하기 때문에 기존의 페이지를 가지는 방식과 여러 가지 다른 점이 있다. 스프링 시큐리티는 기본적으로 다른 도메인에서 Ajax 호출을 차단하기 때문에 이를 위한 CORS 설정과 GET 방식 외의 호출 시에 CSRF 공격을 막기 위한 설정이 기본으로 활성화되어 있으므로 이를 변경해주고, 개발해야 한다.


[CORS, CSRF 설정]

* 시큐리티와 관련된 설정 중에 기존의 CustomServletConfig에 있던 CORS 관련 설정은 삭제하고 CustomSecurityConfig에 CORS 관련 설정을 추가한다.

package com.unlimited.mallapi.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import com.unlimited.mallapi.controller.formatter.LocalDateFormatter;

@Configuration
public class CustomServletConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addFormatter(new LocalDateFormatter());
    }

}

* CORS 설정은 CustomSecurityConfig 내부로 변경한다.

package com.unlimited.mallapi.config;

import java.util.Arrays;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@Configuration
@Log4j2
@RequiredArgsConstructor
public class CustomSecurityConfig {

    /**
     * Security 필터 체인을 설정합니다.
     *
     * @param http HttpSecurity 객체
     * @return SecurityFilterChain 객체
     * @throws Exception 예외 처리
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        log.info("-----------------보안 설정-----------------");

        // CORS 구성을 설정합니다.
        http.cors(corsConfig -> {
            corsConfig
                    .configurationSource(corsConfigurationSource());
        });

        // 기타 보안 설정을 추가할 수 있습니다.

        return http.build();
    }

    /**
     * CORS(Cross-Origin Resource Sharing) 구성을 설정합니다.
     *
     * @return CorsConfigurationSource 객체
     */
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        // 허용된 Origin 패턴을 설정합니다.
        configuration.setAllowedOriginPatterns(Arrays.asList("*"));
        // 허용된 HTTP 메서드를 설정합니다.
        configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE"));
        // 허용된 HTTP 헤더를 설정합니다.
        configuration.setAllowedHeaders(Arrays.asList("Authorization", "Cache-Control", "Content-Type"));
        // 인증 정보를 허용할지 여부를 설정합니다.
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        // 모든 경로에 대해 CORS 구성을 적용합니다.
        source.registerCorsConfiguration("/**", configuration);

        return source;
    }

}

* CSRF 설정은 GET 방식을 제외한 모든 요청에 CSRF 토큰이라는 것을 사용하는 방식이다. 하지만 API 서버를 작성할 때는 사용하지 않는 것이 일반적이므로 설정을 추가한다. 또한, API 서버는 무상태(stateless)를 기본으로 사용하기 때문에 서버 내부에서 세션을 생성하지 않도록 추가한다.

    /**
     * Security 필터 체인을 설정합니다.
     *
     * @param http HttpSecurity 객체
     * @return SecurityFilterChain 객체
     * @throws Exception 예외 처리
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        log.info("-----------------보안 설정-----------------");

        // CORS 구성을 설정합니다.
        http.cors(corsConfig -> {
            corsConfig
                    .configurationSource(corsConfigurationSource());
        });

        // import org.springframework.security.config.http.SessionCreationPolicy;
        // 세션 생성 정책을 무상태로 설정합니다.
        http.sessionManagement(sessionConfig -> sessionConfig.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        // CSRF 보안을 비활성화합니다.
        http.csrf(config -> config.disable());

        return http.build();
    }

[PasswordEncoder 설정]

* 스프링 시큐리티는 사용자의 패스워드에 PasswordEncoder라는 것을 설정해 주어야 한다. CustomSecurityConfig 에 기본적으로 제공되는 org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder를 지정하는 코드를 추가한다(import 필요).

    /**
     * 비밀번호를 안전하게 해싱하는 PasswordEncoder 빈을 생성합니다.
     *
     * import org.springframework.security.crypto.password.PasswordEncoder;
     * import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
     * 
     * @return BCryptPasswordEncoder 객체
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCryptPasswordEncoder를 사용하여 비밀번호를 안전하게 해싱합니다.
        return new BCryptPasswordEncoder();
    }

(2) Member 엔티티 처리

* 프로젝트 내 회원을 의미하는 Member.java와 회원의 권한을 의미하는 MemberRole.java를 domain 패키지에 추가하고 엔티티로 필요한 코드를 추가한다.

* MemberRole.java는 회원의 권한을 의미하는데 Member에서는 @ElementCollection으로 처리한다(하나의 멤버는 여러 개의 권한을 가질 수 있는 1:N 관계이기 때문이다).

package com.unlimited.mallapi.domain;

/**
 * 회원 역할을 정의한 열거형입니다.
 * - USER: 일반 사용자 역할
 * - MANAGER: 매니저 역할
 * - ADMIN: 관리자 역할
 */
public enum MemberRole {
    USER, MANAGER, ADMIN;
}

* Member는 회원의 기본 정보와 권한 목록을 가지도록 설계한다.

package com.unlimited.mallapi.domain;

import java.util.ArrayList;
import java.util.List;

import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

/**
 * 회원 정보를 담은 엔티티 클래스입니다.
 */
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = "memberRoleList")
public class Member {

    @Id
    private String email; // 이메일

    private String pw; // 비밀번호

    private String nickname; // 닉네임

    private boolean social; // 소셜 로그인 여부

    @ElementCollection(fetch = FetchType.LAZY)
    @Builder.Default
    private List<MemberRole> memberRoleList = new ArrayList<>(); // 회원 역할 목록

    /**
     * 회원에게 역할을 추가합니다.
     *
     * @param memberRole 추가할 회원 역할
     */
    public void addRole(MemberRole memberRole) {
        memberRoleList.add(memberRole);
    }

    /**
     * 회원의 모든 역할을 제거합니다.
     */
    public void clearRole() {
        memberRoleList.clear();
    }

    /**
     * 닉네임을 변경합니다.
     *
     * @param nickname 변경할 닉네임
     */
    public void changeNickName(String nickname) {
        this.nickname = nickname;
    }

    /**
     * 비밀번호를 변경합니다.
     *
     * @param pw 변경할 비밀번호
     */
    public void changePw(String pw) {
        this.pw = pw;
    }

    /**
     * 소셜 로그인 여부를 변경합니다.
     *
     * @param social 소셜 로그인 여부
     */
    public void changeSocial(boolean social) {
        this.social = social;
    }

}

* repository 패키지 내에는 MemberRepository 인터페이스를 추가한다. MemberRepository 에는 조회 시에 권한의 목록까지 같이 로딩하도록 getWithRoles() 메서드를 같이 작성해 준다.

package com.unlimited.mallapi.repository;

import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.unlimited.mallapi.domain.Member;

/**
 * 회원 정보에 대한 데이터 액세스를 담당하는 JpaRepository 인터페이스입니다.
 */
public interface MemberRepository extends JpaRepository<Member, String> {

    /**
     * 회원의 역할 정보를 포함하여 이메일로 회원 정보를 조회합니다.
     *
     * @param email 조회할 회원의 이메일
     * @return 회원 정보 및 역할 정보를 포함한 Member 엔티티
     */
    @EntityGraph(attributePaths = { "memberRoleList" })
    @Query("select m from Member m where m.email = :email")
    Member getWithRoles(@Param("email") String email);

}

(3) 테스트 코드를 이용한 등록/조회 확인

* 테스트 폴더의 repository 패키지를 사용해서 MemberRepositoryTests.java 파일을 추가하고 엔티티의 등록과 조회를 확인한다. 시큐리티를 활용하면 사용자의 패스워드는 반드시 PasswordEncoder를 이용한 암호화된 값으로 하기 때문에 사용자 생성 시 PasswordEncoder의 encode()를 사용해서 값을 처리한다.

package com.unlimited.mallapi.repository;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;

import com.unlimited.mallapi.domain.Member;
import com.unlimited.mallapi.domain.MemberRole;

import lombok.extern.log4j.Log4j2;

@SpringBootTest
@Log4j2
public class MemberRepositoryTests {

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    /**
     * 회원을 데이터베이스에 등록하는 테스트 메서드입니다.
     */
    @Test
    public void testInsertMember() {
        for (int i = 0; i < 10; i++) {
            // 회원 객체 생성
            Member member = Member.builder()
                    .email("user" + i + "@aaa.com")
                    .pw(passwordEncoder.encode("1111"))
                    .nickname("USER" + i)
                    .build();

            // 회원 역할 추가
            member.addRole(MemberRole.USER);

            if (i >= 5) {
                member.addRole(MemberRole.MANAGER);
            }

            if (i >= 8) {
                member.addRole(MemberRole.ADMIN);
            }

            // 데이터베이스에 저장
            memberRepository.save(member);
        }
    }

    /**
     * 이메일로 회원을 조회하고 역할 정보를 함께 출력하는 테스트 메서드입니다.
     */
    @Test
    public void testRead() {
        String email = "user9@aaa.com";

        // 이메일로 회원 조회
        Member member = memberRepository.getWithRoles(email);

        log.info("---------------------");
        log.info(member);
    }

}

* testInsertMember() 는 루프 변수의 값에 따라서 USER 혹은 USER, MANAGER 혹은 USER, MANAGER, ADMIN 권한을 가지는 사용자를 생성하게 된다. 예를 들어 마지막 user9@aaa.com은 아래와 같이 여러 개의 권한이 추가된다.

* 사용자의 패스워드는 BCryptPasswordEncoder를 이용한 결과를 저장하는데 BCrypt 방식은 동일한 문자열도 매번 다른 결과를 생성한다.

* testRead()는 특정 이메일의 사용자 정보와 사용자의 권한 값들을 한 번에 조인 처리해서 처리하는 것을 확인할 수 있다.