관리 메뉴

거니의 velog

(28) 스프링 시큐리티와 API 서버 2 본문

SpringBoot_React 풀스택 프로젝트

(28) 스프링 시큐리티와 API 서버 2

Unlimited00 2024. 3. 5. 21:02

2. DTO와 인증 처리 서비스

* 엔티티 처리를 확인했다면 서비스 계층을 만들어서 로그인 기능을 구현해 놓는다. 가장 먼저 dto 패키지에 MemberDTO 클래스를 추가한다. MemberDTO는 기존의 DTO와는 달리 스프링 시큐리티에서 이용하는 타입의 객체로 만들어서 사용하기 위해서 org.springframework.security.core.userdetails.User 클래스를 상속하는 구조로 생성하고, User 클래스의 생성자를 사용할 수 있는 구조로 작성한다(상속하는 부모 클래스의 생성자 함수 때문에 생성자 방식을 사용한다).

package com.unlimited.mallapi.dto;

import java.util.List;
import java.util.ArrayList;
import java.util.Map;
import java.util.HashMap;
import java.util.stream.Collectors;

import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

/**
 * 회원 정보를 전달하기 위한 데이터 전송 객체(DTO) 클래스입니다.
 */
@Getter
@Setter
@ToString
public class MemberDTO extends User {

    private String email; // 이메일

    private String pw; // 비밀번호

    private String nickname; // 닉네임

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

    private List<String> roleNames = new ArrayList<>(); // 회원 역할 목록

    /**
     * MemberDTO 생성자입니다.
     *
     * @param email     이메일
     * @param pw        비밀번호
     * @param nickname  닉네임
     * @param social    소셜 로그인 여부
     * @param roleNames 회원 역할 목록
     */
    public MemberDTO(String email, String pw, String nickname, boolean social, List<String> roleNames) {
        super(email, pw,
                roleNames.stream().map(str -> new SimpleGrantedAuthority("ROLE_" + str)).collect(Collectors.toList()));

        this.email = email;
        this.pw = pw;
        this.nickname = nickname;
        this.social = social;
        this.roleNames = roleNames;
    }

    /**
     * JWT(Claims)에 담을 정보를 반환하는 메서드입니다.
     *
     * @return 회원 정보 및 역할 정보를 포함한 Map 객체
     */
    public Map<String, Object> getClaims() {
        Map<String, Object> dataMap = new HashMap<>();

        dataMap.put("email", email);
        dataMap.put("pw", pw);
        dataMap.put("nickname", nickname);
        dataMap.put("social", social);
        dataMap.put("roleNames", roleNames);

        return dataMap;
    }

}

* MemberDTO 안에는 getClaims() 라는 메서드를 추가해서 현재 사용자의 정보를 Map 타입으로 반환하도록 구성했는데, 이는 나중에 JWT 문자열 생성 시에 사용하기 위함이다.


(1) UserDetailsService 구현

* 스프링 시큐리티는 사용자의 인증 처리를 위해서 UserDetailsService라는 인터페이스의 구현체를 활용한다. 이를 커스터마이징 하기 위해서 프로젝트 내에 security 패키지(폴더)를 생성하고 CustomUserDetailsService 클래스를 추가해 준다.

package com.unlimited.mallapi.security;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.unlimited.mallapi.repository.MemberRepository;

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

/**
 * Spring Security에서 사용자 정보를 로드하는 커스텀 UserDetailsService 구현 클래스입니다.
 */
@Service
@Log4j2
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    /**
     * 사용자명(이메일)을 기반으로 사용자 정보를 로드합니다.
     *
     * @param username 사용자명(이메일)
     * @return UserDetails 객체
     * @throws UsernameNotFoundException 사용자가 존재하지 않는 경우 발생하는 예외
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("---------------loadUserByUsername---------------");
        return null;
    }

}

* 시큐리티를 적용하면 CustomUserDetailsService의 loadUserByUsername() 에서 사용자 정보를 조회하고 해당 사용자의 인증과 권한을 처리하게 된다. 현재 코드는 null을 반환하기 때문에 문제가 발생하겠지만 로그를 통해서 동작 여부를 확인할 수는 있다.
* API 서버로 로그인할 수 있도록 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());
        });

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

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

        // 로그인 페이지를 지정합니다.
        http.formLogin(loginConfig -> {
            loginConfig.loginPage("/api/member/login");
        });

        return http.build();
    }

* formLogin 설정을 추가하면 스프링 시큐리티는 POST 방식으로 username과 password 라는 파라미터를 통해서 로그인을 처리할 수 있게 된다.
* Postman을 통해서 /api/member/login을 POST 방식으로 호출하면 서버상에서 로그가 출력되는 것을 확인할 수 있다(실행 로그의 조금 위쪽에 예외 메시지 전에 출력된다).

* CustomUserDetailsService의 동작을 확인했다면 MemberRepository에서 사용자의 정보를 조회해서 MemberDTO 타입으로 반환하도록 구현한다.

package com.unlimited.mallapi.security;

import java.util.stream.Collectors;

import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import com.unlimited.mallapi.domain.Member;
import com.unlimited.mallapi.dto.MemberDTO;
import com.unlimited.mallapi.repository.MemberRepository;

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

/**
 * Spring Security에서 사용자 정보를 로드하는 커스텀 UserDetailsService 구현 클래스입니다.
 */
@Service
@Log4j2
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    /**
     * 사용자명(이메일)을 기반으로 사용자 정보를 로드합니다.
     *
     * @param username 사용자명(이메일)
     * @return UserDetails 객체
     * @throws UsernameNotFoundException 사용자가 존재하지 않는 경우 발생하는 예외
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("---------------loadUserByUsername---------------");

        // 사용자명(이메일)을 기반으로 사용자 정보를 조회합니다.
        Member member = memberRepository.getWithRoles(username);

        // 사용자가 존재하지 않는 경우 예외를 발생시킵니다.
        if (member == null) {
            throw new UsernameNotFoundException("Not Found");
        }

        // Member 엔티티를 기반으로 MemberDTO를 생성합니다.
        MemberDTO memberDTO = new MemberDTO(
                member.getEmail(),
                member.getPw(),
                member.getNickname(),
                member.isSocial(),
                member.getMemberRoleList()
                        .stream()
                        .map(memberRole -> memberRole.name()).collect(Collectors.toList()));

        log.info(memberDTO);

        return memberDTO;
    }

}

* 변경된 코드를 Postman으로 호출하면 MemberRepository의 동작 과정에서 SQL이 실행되는 것을 확인할 수 있다. 다만, 아직 Postman의 결과는 에러가 발생하는데 이는 스프링 시큐리티가 기본적으로 로그인이 되었을 때 '/' 경로로 이동하기 때문이다.

{
    "timestamp": "2024-03-05T12:40:57.649+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "No static resource .",
    "path": "/"
}

(2) 로그인 성공 후 JSON 데이터 생성

* 로그인에 성공한 후에는 AuthenticationSuccessHandler라는 것을 통해서 후처리 작업이 가능하다. API 서버의 경우 로그인 후에는 JSON 문자열을 생성해서 전달하도록 구현한다.
* JSON 문자열의 생성은 Gson 라이브러리를 활용해서 객체를 JSON 문자열로 처리한다. build.gradle 파일에 Gson 라이브러리를 추가한다.

dependencies {
	(...)
	implementation 'com.google.code.gson:gson:2.10.1'
}

* 프로젝트의 security 패키지 내에는 추가적으로 handler 패키지를 추가하고 APILoginSuccessHandler 클래스를 추가한다.

package com.unlimited.mallapi.security.handler;

import java.io.IOException;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class APILoginSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {

        log.info("-------------------------------------------");
        log.info(authentication);
        log.info("-------------------------------------------");

    }

}

* CustomSecurityConfig에는 로그인 후 처리를 APILoginSuccessHandler로 설정한다.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        log.info("-----------------보안 설정-----------------");

        (...)

        // 로그인 페이지를 지정합니다.
        http.formLogin(loginConfig -> {
            loginConfig.loginPage("/api/member/login");
            // import com.unlimited.mallapi.security.handler.APILoginSuccessHandler;
            loginConfig.successHandler(new APILoginSuccessHandler());
        });

        return http.build();
    }

* 변경된 설정을 Postman으로 실행해 보면 로그에서 APILoginSuccessHandler의 동작을 확인할 수 있다(아직 결과 데이터는 브라우저로 전송되지 않은 상황이다).


[JSON 결과의 전송]

* 로그인에 성공하면 APILoginSuccessHandler를 활용해서 응답 데이터를 생성한다. 작성하는 응답 메시지는 MemberDTO 안에 있는 getClaims()를 통해서 JSON 데이터를 구성한다.

package com.unlimited.mallapi.security.handler;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

import com.google.gson.Gson;
import com.unlimited.mallapi.dto.MemberDTO;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class APILoginSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(
            HttpServletRequest request,
            HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {

        // 로그인 성공 시 실행되는 메소드
        log.info("-------------------------------------------");
        log.info(authentication);
        log.info("-------------------------------------------");

        // 인증된 사용자 정보 추출
        MemberDTO memberDTO = (MemberDTO) authentication.getPrincipal();

        // 사용자의 클레임 정보 추출
        Map<String, Object> claims = memberDTO.getClaims();

        // Access Token과 Refresh Token에 대한 빈 값 할당(추후 구현)
        claims.put("accessToken", "");
        claims.put("refreshToken", "");

        // Gson 라이브러리를 사용하여 Map 객체를 JSON 문자열로 변환
        Gson gson = new Gson();
        String jsonStr = gson.toJson(claims);

        // HTTP 응답 헤더 및 바디 설정
        response.setContentType("application/json; charset=UTF-8");
        PrintWriter printWriter = response.getWriter();
        printWriter.println(jsonStr);
        printWriter.close();
    }

}

* 구현된 코드의 내부는 나중에 생성해서 전달하려는 Access Token과 Refresh Token을 미리 넣을 수 있는 형태로 작성되었다. 이 상태에서 정상적인 로그인 결과는 Postman에서 JSON 결과를 만들어 전송한다(pw는 나중에 전송하지 않도록 수정하는 것이 좋지만, 예제에서는 확인 차원에서 추가하였다). 아래의 화면은 'user9@aaa.com/1111' 로 정상적인 로그인을 시도한 결과이다.


[인증 실패 처리]

* API 서버의 호출 결과 로그인을 할 수 없는 사용자라면 org.springframework.security.authentication.BadCredintialsException 타입의 예외가 발생하게 되는데 이에 대한 메시지 역시 JSON 문자열을 생성해서 전송해 주어야 한다. 성공의 경우와 마찬가지로 실패의 경우에도 AuthenticationFailureHandler 인터페이스를 구현해서 이를 처리한다.
* handler 패키지에 APILoginFailHandler 클래스를 추가한다. 만일 로그인에 실패하면 예외 메시지를 JSON으로 전달하도록 작성한다.

package com.unlimited.mallapi.security.handler;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Map;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

import com.google.gson.Gson;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.log4j.Log4j2;

@Log4j2
public class APILoginFailHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(
            HttpServletRequest request,
            HttpServletResponse response,
            AuthenticationException exception) throws IOException, ServletException {

        // 로그인 실패 시 실행되는 메소드
        log.info("Login fail..." + exception);

        // Gson 라이브러리를 사용하여 에러 메시지를 JSON 형식으로 변환
        Gson gson = new Gson();
        String jsonStr = gson.toJson(Map.of("error", "ERROR_LOGIN"));

        // HTTP 응답 헤더 및 바디 설정
        response.setContentType("application/json");
        PrintWriter printWriter = response.getWriter();
        printWriter.println(jsonStr);
        printWriter.close();
    }

}

* CustomSecurityConfig에서는 로그인 실패 처리 시에 대한 설정을 추가한다.

    @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(csrfConfig -> csrfConfig.disable());

        // 로그인 페이지를 지정합니다.
        http.formLogin(loginConfig -> {
            loginConfig.loginPage("/api/member/login");
            // import com.unlimited.mallapi.security.handler.APILoginSuccessHandler;
            loginConfig.successHandler(new APILoginSuccessHandler());
            // import com.unlimited.mallapi.security.handler.APILoginFailHandler;
            loginConfig.failureHandler(new APILoginFailHandler());
        });

        return http.build();
    }

* 변경된 설정을 반영한 후에 Postman에서 잘못된 이메일이나 패스워드를 전달하면 상태 코드는 200인 정상적인 응답이기는 하지만 다음과 같은 결과를 받게 된다.