Spring Boot 기반 다수의 프로젝트를 하나의 Multi Module 프로젝트로 통합하기 (2) - 인증과 인가를 담당하는 Auth Api

1. 인증과 인가

인증과 인가. 많이 들어 본 말이지만 들을 때 마다 햇갈리는 단어입니다. 때문에 쉽게 생각해야 할 필요성이 있었고 저는 이와 같이 정의를 내렸습니다. 인증은 사용자가 자신의 신원을 확인하는 모든 행위라고 생각합니다. 인가는 인증을 통해 신원이 확인된 사용자에게 특정 액세스 권한을 부여하는 모든 행위를 뜻 한다고 생각합니다.

 

더 쉽게 개발적인 이야기로 얘기하자면, 인증은 ID/PW, JWT Token 등과 같은 모든 자신의 신원을 확인하는 신분증이고 인가는 그런 인증에 대해 엑세스 권한을 체크 및 부여하는 행위라고 생각하면 됩니다.

 

저는 인증과 인가는 Gateway에서 처리하고, 해당 인증과 인가에 필요한 회원 확인 및 JWT Token 발급은 Auth Service(이하 Auth API)에서 처리하도록 설계하려 합니다.

 

하지만 Gateway 구현 이전에는 Auth API에서 모든 인증,인가,토큰발급 등을 관리하게 구현합니다.


2. Auth API 

Member 및 Token 관련 Entity, Repository, Service, Controller로 이뤄진 Auth API를 아래와 같이 설계하였습니다. 인증과 인가를 더욱 쉽게 만들어주는 Spring Security를 함께 사용하였습니다.

build.gradle

# module-api/build.gradle
subprojects {
    dependencies {
        implementation project(':module-core')

        implementation 'org.springframework.boot:spring-boot-starter-web'
        implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    }
}

# module-api/auth-api/build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
}

 

위와같이 설정하면 module-api 모듈의 하위에 있는 모든 모듈에게 starter-web, data-jpa가 주입되어 하위 모듈에서 일일이 의존성을 주입받을 필요가 없이 꼭 필요한 것만 주입받으면 되기 때문에 gradle.build 파일이 더욱 간결해지고 제 기준에서 가독성있게 파악할 수 있게 되는 장점으로 다가왔습니다.

application.yml

server:
  port: 18081

spring:
  application:
    name: auth-api
  profiles:
    include:
      - database

# JWT Configuration
jwt:
  access:
    secret: 9295d3232d3428c866241359d89485431868fa3c0207ab2fef5b2dff1a371ea95a9df0b74588f1d34bb6c2ce0078e39d8b9faf32c36cf97b56c09e8876007416
    expire: 86400000
  refresh:
    secret: e3545b171440dc2bddb1757e49c8b1470221d243f986a19dec44669d3c0d0f9437b4cf769756b9a2197e605e9b1924bb58faac5f578424be00ea92369fcc521d
    expire: 2592000000

 

jwt의 access secret 값과 refresh secret값은 임의로 위와같이 만들어두었지만, 실제 운영에서는 개발자도 알 수 없게 감추어두고 사용해야 한다는 것을 꼭 알아야 합니다. 저 값들로 정보를 토큰화하고, 토큰을 인증할 수 있기 때문에 아주 중요하게 다루고 관리해야 합니다.


UUID 사용

저는 Entity를 생성할 때 BINARY(16) 형태의 UUID로 primary 컬럼을 설정하는것을 좋아합니다. 많은 장점이 있지만 대표적인 장점 몇가지를 적어보겠습니다.

  1.  저장 공간 효율성
    • 일반적으로 UUID는 CHAR(36) 형식으로 저장되며, 36바이트를 차지합니다. BINARY(16)로 저장할 경우 16바이트만 사용하므로, 공간 효율성이 약 56% 향상됩니다.
    • 대량의 데이터를 저장하는 테이블에서 Primary Key로 BINARY(16) UUID를 사용하면, 인덱스 크기가 줄어들어 메모리 및 디스크 사용량을 줄일 수 있습니다.
  2. 데이터베이스 성능 향상
    • BINARY(16) UUID는 숫자 기반으로 정렬이 더 효율적이며, 인덱스에서도 빠른 조회 성능을 제공합니다.
      UUID의 난수 특성상 대량의 데이터가 쌓일 경우 인덱스의 균형을 유지하는 데 도움이 되며, 삽입 시 충돌이 적어 B-Tree 인덱스의 성능 저하를 줄여줍니다.
  3. 보안 및 추적성 강화
    • UUID는 일반적인 Auto Increment ID와 달리 추측하기 어려우며, 외부에 노출되더라도 의미를 유추할 수 없습니다.
      BINARY 형식의 UUID는 예측이 어렵고, 각 레코드의 고유성이 보장되므로 보안성을 높일 수 있습니다. 다중 시스템 간 통합 시에도 각 시스템의 고유성을 유지할 수 있습니다.
  4. 다중 시스템 간 데이터 통합에 유리
    • 분산 시스템이나 마이크로서비스 아키텍처에서는 각 서비스가 고유한 UUID를 생성하여 데이터를 저장하는 것이 중요합니다.
      BINARY(16) UUID는 이러한 고유성을 보장하면서도 빠른 전송과 최소한의 네트워크 대역폭 사용을 지원합니다.
  5. 간편한 인덱스 유지 관리
    • BINARY(16) UUID는 일반적인 텍스트 형식보다 작고 정렬이 용이하기 때문에, 대규모 인덱스에서 관리가 더 수월합니다.
      자주 삽입되고 조회되는 테이블에서는 인덱스의 크기를 줄이고 데이터베이스의 캐시 사용을 효율화할 수 있습니다.

Entity 생성

간혹 Entity를 생성할 때 Setter를 만들어 사용하시는 분들이 계실던데 저는 프로그래머 간의 실수가 자주 일어나게 되고 원치 않는 값이 들어가는 등의 이유로 Mapper 사용을 좋아합니다. 이 점 참고하여 이 포스팅을 토대로 구현하고 계시는 분들 중 Setter를 사용해야 하시는 분들은 입맞에 맞게 수정해서 사용하시면 됩니다.

Member Entity

@Getter
@Entity
@Table(name = "members")
@NoArgsConstructor
public class Member extends DefaultTime {

    @Id
    @UuidGenerator(style = UuidGenerator.Style.TIME)
    @GeneratedValue(generator = "uuid2")
    @Column(columnDefinition = "BINARY(16)")
    private UUID id;
    @Column(columnDefinition = "varchar(100)", nullable = false)
    private String name;
    @Column(columnDefinition = "varchar(100)", nullable = false, unique = true)
    private String email;
    @Column(columnDefinition = "varchar(100)", nullable = false)
    private String password;
    @Column(columnDefinition = "varchar(100)", nullable = false, unique = true)
    private String phone;
    @Column(columnDefinition = "varchar(300)", nullable = false)
    private String address;
    @Enumerated(EnumType.STRING)
    @ColumnDefault("'USER'")
    @Column(nullable = false)
    private Authority role;
    @ColumnDefault("false")
    @Column(columnDefinition = "TINYINT(1)", nullable = false)
    private boolean isActive;

    public Member(String name, String email, String password, String phone, String address) {
        this.name = name;
        this.email = email;
        this.password = password;
        this.phone = phone;
        this.address = address;
        this.role = Authority.USER;
    }

    public void update(UpdateMemberRequestDto updateMemberRequestDto) {
        this.name = updateMemberRequestDto.getName();
        this.password = updateMemberRequestDto.getPassword();
        this.phone = updateMemberRequestDto.getPhone();
        this.address = updateMemberRequestDto.getAddress();
    }
}

Token Entity


@Getter
@Entity
@Builder
@Table(name = "tokens")
@NoArgsConstructor
@AllArgsConstructor
public class Token extends DefaultTime {
    @Id
    @UuidGenerator(style = UuidGenerator.Style.TIME)
    @GeneratedValue(generator = "uuid2")
    @Column(columnDefinition = "BINARY(16)")
    private UUID id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @Column(columnDefinition = "varchar(500)", nullable = false)
    private String accessToken;

    @Column(columnDefinition = "varchar(500)", nullable = false)
    private String refreshToken;

    @Column(nullable = false)
    private String grantType;

    public Token update(String accessToken) {
        this.accessToken = accessToken;
        return this;
    }

    public Token update(String accessToken, String refreshToken, String grantType) {
        return Token.builder()
                .id(this.id)
                .member(this.member)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .grantType(grantType)
                .build();
    }

    public Token update(UUID id, Member member, String accessToken, String refreshToken, String grantType) {
        return Token.builder()
                .id(id)
                .member(member)
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .grantType(grantType)
                .build();
    }
}

저의 경우 위에 언급 한 것 처럼 Entity를 생성할 때 Setter 패턴은 사용하지 않지만, 꼭 필요할 경우 Builder패턴을 사용하여 생성할 수 있도록 개발하는 편입니다.

 


JWT Token생성

JWT 토큰 및 Request에서 토큰을 추출하여 인증하는 Filter 등 JWT 관련 Class를 생성합니다.

JwtTokenizer

@Component
public class JwtTokenizer {

    private static String  accessSecret;
    private static String refreshSecret;
    public static Long accessTokenExpire;
    public static Long refreshTokenExpire;

    @Value("${jwt.access.secret}")
    private String  accessSecretValue;
    @Value("${jwt.refresh.secret}")
    private String refreshSecretValue;
    @Value("${jwt.access.expire}")
    public Long accessTokenExpireValue;
    @Value("${jwt.refresh.expire}")
    public Long refreshTokenExpireValue;

    @PostConstruct
    public void init() {
        accessSecret = accessSecretValue;
        refreshSecret = refreshSecretValue;
        accessTokenExpire = accessTokenExpireValue;
        refreshTokenExpire = refreshTokenExpireValue;
    }

    /**
     * AccessToken 생성
     *
     * @param id
     * @param email
     * @param name
     * @param authority
     * @return AccessToken
     */
    public String createAccessToken(UUID id, String email, String name, Authority authority) {
        return createToken(id, email, name, authority, accessTokenExpire, accessSecret);
    }

    /**
     * RefreshToken 생성
     *
     * @param id
     * @param email
     * @param name
     * @param authority
     * @return RefreshToken
     */
    public String createRefreshToken(UUID id, String email, String name, Authority authority) {
        return createToken(id, email, name, authority, refreshTokenExpire, refreshSecret);
    }

    /**
     * Jwts 빌더를 사용하여 token 생성
     *
     * @param id
     * @param email
     * @param name
     * @param authority
     * @param expire
     * @param secretKey
     * @return
     */
    private String createToken(UUID id, String email, String name, Authority authority, Long expire, String secretKey) {
        byte[] secretByte = getSecretByte(secretKey);


        Claims claims = Jwts.claims().setSubject(email)
                .add("authority", authority)
                .add("id", id)
                .add("name", name)
                .build();

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date())
                .setExpiration(new Date(new Date().getTime() + expire))
                .signWith(getSigningKey(secretByte))
                .compact();
    }

    /**
     * 토큰에서 유저 아이디 얻기
     *
     * @param token 토큰
     * @return userId
     */
    public String getUserIdFromToken(String token) {
        String[] tokenArr = token.split(" ");
        token = tokenArr[1];
        Claims claims = parseToken(token, getSecretByte(accessSecret));
        return claims.get("id") == null ? null : claims.get("id").toString();
    }

    public Claims parseAccessToken(String accessToken) {
        return parseToken(accessToken, getSecretByte(accessSecret));
    }

    public Claims parseRefreshToken(String refreshToken) {
        return parseToken(refreshToken, getSecretByte(refreshSecret));
    }

    public Claims parseToken(String token, byte[] secretKey) {
        Claims claims = null;
        try {
            claims = Jwts.parser()
                    .setSigningKey(getSigningKey(secretKey))
                    .build()
                    .parseClaimsJws(token)
                    .getBody();

        } catch (SignatureException e) { // 토큰 유효성 체크 실패 시
            throw new PlayGroundCommonException(PlayGroundErrorCode.JWT_INVALID_ERROR.getCode(), PlayGroundErrorCode.JWT_INVALID_ERROR.getMessage());
        }

        return claims;
    }

    /**
     * @param secretKey - byte형식
     * @return Key 형식 시크릿 키
     */
    public static Key getSigningKey(byte[] secretKey) {
        return Keys.hmacShaKeyFor(secretKey);
    }

    private static byte[] getSecretByte(String secret) {
        return secret.getBytes(StandardCharsets.UTF_8);
    }
}

 

해당 Class는 JWT Token을 발행 및 인증하는 중요한 역할을 담당합니다.

ToeknAuthenticationFilter

@Slf4j
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenizer jwtTokenizer;

    /**
     * 필터 메서드
     * 각 요청마다 JWT 토큰을 검증하고 인증을 설정
     *
     * @param request     요청 객체
     * @param response    응답 객체
     * @param filterChain 필터 체인
     * @throws ServletException 서블릿 예외
     * @throws IOException      입출력 예외
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = getToken(request); // 요청에서 토큰을 추출

        if (StringUtils.hasText(token)) {
            try {
                // 토큰을 사용하여 인증 설정
                getAuthentication(token);
            } catch (ExpiredJwtException e) { // 토큰 만료 시
                request.setAttribute("exception", "EXPIRED_TOKEN");
                log.error("Expired Token : {}", token, e);
                throw new PlayGroundCommonException(PlayGroundErrorCode.JWT_EXPIRED_ERROR.getCode(), PlayGroundErrorCode.JWT_EXPIRED_ERROR.getMessage(), e);
            } catch (UnsupportedJwtException e) { // 지원하지 않는 토큰 사용 시
                request.setAttribute("exception", "UNSUPPORTED_TOKEN");
                log.error("Unsupported Token: {}", token, e);
                throw new PlayGroundCommonException(PlayGroundErrorCode.JWT_UNSUPPORTED_ERROR.getCode(), PlayGroundErrorCode.JWT_UNSUPPORTED_ERROR.getMessage(), e);
            } catch (MalformedJwtException e) { // 유효하지 않은 토큰 사용 시
                request.setAttribute("exception", "INVALID_TOKEN");
                log.error("Invalid Token: {}", token, e);
                throw new PlayGroundCommonException(PlayGroundErrorCode.JWT_INVALID_ERROR.getCode(), PlayGroundErrorCode.JWT_INVALID_ERROR.getMessage(), e);
            } catch (IllegalArgumentException e) { // 올바르지 않은 파라미터 전달 시
                request.setAttribute("exception", "NOT_FOUND_TOKEN");
                log.error("Token not found: {}", token, e);
                throw new PlayGroundCommonException(PlayGroundErrorCode.JWT_TOKEN_NOT_FOUND.getCode(), PlayGroundErrorCode.JWT_TOKEN_NOT_FOUND.getMessage(), e);
            } catch (Exception e) { // 알 수 없는 예외 발생 시
                request.setAttribute("exception", "NOT_FOUND_TOKEN");
                log.error("JWT Filter - Internal Error: {}", token, e);
                throw new PlayGroundCommonException(PlayGroundErrorCode.JWT_COMMON_ERROR.getCode(), PlayGroundErrorCode.JWT_COMMON_ERROR.getMessage(), e);
            }
        }

        filterChain.doFilter(request, response); // 다음 필터로 요청을 전달
    }

    /**
     * 토큰을 사용하여 인증 설정
     *
     * @param token JWT 토큰
     */
    private void getAuthentication(String token) {
        Claims claims = jwtTokenizer.parseAccessToken(token); // 토큰에서 클레임을 파싱
        String email = claims.getSubject(); // 이메일을 가져옴
        String id = claims.get("id", String.class); // 사용자 ID를 가져옴
        String name = claims.get("name", String.class); // 이름을 가져옴
        Authority authority = Authority.valueOf(claims.get("authority", String.class)); // 사용자 권한을 가져옴

        Collection<? extends GrantedAuthority> authorities = Collections.singletonList(authority);

        CustomUserDetails userDetails = new CustomUserDetails(email, "", (List<GrantedAuthority>) authorities);
        Authentication authentication = new JwtAuthenticationToken(authorities, userDetails, null); // 인증 객체 생성
        SecurityContextHolder.getContext().setAuthentication(authentication); // SecurityContextHolder에 인증 객체 설정
    }

    /**
     * 요청에서 토큰을 추출
     *
     * @param request 요청 객체
     * @return JWT 토큰
     */
    private String getToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies(); // 쿠키에서 토큰을 찾음

        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("accessToken".equals(cookie.getName())) {
                    return cookie.getValue(); // accessToken 쿠키에서 토큰 반환
                }
            }
        }

        return null; // 토큰을 찾지 못한 경우 null 반환
    }
}

해당 Class는 Spring Security에서 Filter Chain 방식으로 사용합니다.


Service Class

Service Class를 활용하는 방법은 개발자별로 천지차이입니다. 혹자는 Repository 사용법을 그대로 사용하되 여러 Repository를 묶기 위해 Service Class를 활용하기도 하고, 저처럼 Controller를 목차처럼 사용하기 위해 Service Class에서 비지니스로직을 구현하는 개발자도 있습니다. 취향에 맞게 개발하시면 됩니다.

AuthService

@Service
@RequiredArgsConstructor
public class AuthService {

    private final PasswordEncoder passwordEncoder;
    private final JwtTokenizer jwtTokenizer;
    private final TokenService tokenService;
    private final MemberRepository memberRepository;

    public AuthLoginResponseDto login(AuthLoginRequestDto authLoginRequestDto) {
        // 인증 처리
        Member member = this.checkAuthentication(authLoginRequestDto);
        // 토큰 생성 및 저장
        Token token = this.getToken(member);

        return AuthLoginResponseDto.builder()
                .accessToken(token.getAccessToken())
                .refreshToken(token.getRefreshToken())
                .email(member.getEmail())
                .name(member.getName())
                .build();
    }

    /**
     * 인증 처리
     * @param authLoginRequestDto AuthLoginRequestDto
     * @return Member
     */
    private Member checkAuthentication(AuthLoginRequestDto authLoginRequestDto) {
        Member member = memberRepository.findByEmail(authLoginRequestDto.getEmail())
                .orElseThrow(() -> new PlayGroundCommonException(PlayGroundErrorCode.AUTH_INVALID.getCode(), PlayGroundErrorCode.AUTH_INVALID.getMessage()));

        if (member == null) {
            throw new PlayGroundCommonException(PlayGroundErrorCode.AUTH_INVALID.getCode(), PlayGroundErrorCode.AUTH_INVALID.getMessage());
        }

        // 비밀번호 일치 여부 체크
        if (!passwordEncoder.matches(authLoginRequestDto.getPassword(), member.getPassword())) {
            throw new PlayGroundCommonException(PlayGroundErrorCode.AUTH_PASSWORD_MISMATCH.getCode(), PlayGroundErrorCode.AUTH_PASSWORD_MISMATCH.getMessage());
        }

        return member;
    }

    /**
     * 토큰 발급
     * @param member Member
     * @return Token
     */
    private Token getToken (Member member) {

        // 토큰 발급
        String accessToken = jwtTokenizer.createAccessToken(member.getId(), member.getEmail(), member.getName(), member.getRole());
        String refreshToken = jwtTokenizer.createRefreshToken(member.getId(), member.getEmail(), member.getName(), member.getRole());

        // 토큰 저장
        Token token = Token.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .member(member)
                .grantType("Bearer")
                .build();
        tokenService.saveOrUpdate(token);

        return token;
    }

    /**
     * 회원가입
     * @param authSignUpRequestDto AuthSignUpRequestDto
     */
    public void signup(AuthSignUpRequestDto authSignUpRequestDto) {
        if (memberRepository.existsByEmail(authSignUpRequestDto.getEmail())) {
            throw new PlayGroundCommonException(PlayGroundErrorCode.COMMON_ALREADY_EXISTS.getCode(), PlayGroundErrorCode.COMMON_ALREADY_EXISTS.getMessage());
        }

        if (memberRepository.existsByPhone(authSignUpRequestDto.getPhone())) {
            throw new PlayGroundCommonException(PlayGroundErrorCode.COMMON_ALREADY_EXISTS.getCode(), PlayGroundErrorCode.COMMON_ALREADY_EXISTS.getMessage());
        }

        authSignUpRequestDto.setPassword(passwordEncoder.encode(authSignUpRequestDto.getPassword()));
        memberRepository.save(authSignUpRequestDto.toEntity());
    }
}

Token Service

@Service
@RequiredArgsConstructor
public class TokenService {

    private final TokenRepository tokenRepository;

    /**
     * 토큰 사용자의 토큰이 저장되어 있을 경우 update, 없을 경우에는 create
     */
    @Transactional
    public void saveOrUpdate(Token token) {
        Token fineToken = tokenRepository.findByMemberId(token.getMember().getId());

        if (fineToken == null) {
            tokenRepository.save(token);
        } else {
            Token saveToken = token.update(
                    fineToken.getId(),
                    fineToken.getMember(),
                    token.getAccessToken(),
                    token.getRefreshToken(),
                    token.getGrantType()
            );
            tokenRepository.save(saveToken);
        }
    }

    /**
     * access token으로 Token 데이터를 가져와 삭제하는 메소드
     *
     * @param token
     */
    @Transactional
    public void deleteByAccessToken(String token) {
        tokenRepository.findByAccessToken(token).ifPresent(tokenRepository::delete);
    }

    /**
     * access token으로 Token 데이터를 가져오는 메소드
     *
     * @param token access token
     * @return Token 데이터
     */
    @Transactional(readOnly = true)
    public Optional<Token> findByAccessToken(String token) {
        return tokenRepository.findByAccessToken(token);
    }

    /**
     * refresh token으로 Token 데이터를 가져오는 메소드
     *
     * @param token refresh token
     * @return Token 데이터
     */
    @Transactional(readOnly = true)
    public Optional<Token> findByRefreshToken(String token) {
        return tokenRepository.findByRefreshToken(token);
    }

    /**
     * refresh token으로 Token 데이터를 가져와 access token 값을 업데이트하는 메소드
     *
     * @param refreshToken
     * @param accessToken
     */
    @Transactional
    public void updateByRefreshToken(String refreshToken, String accessToken) {
        Token token = tokenRepository.findByRefreshToken(refreshToken)
                .orElseThrow(() -> new PlayGroundCommonException(PlayGroundErrorCode.JWT_TOKEN_NOT_FOUND.getCode(), PlayGroundErrorCode.JWT_TOKEN_NOT_FOUND.getMessage()));

        token = token.update(accessToken);
        tokenRepository.save(token);
    }

}

Controller Class

이제 Auth기능의 RestAPI를 구현한 RestController인 AuthController를 구현해주면 Auth API는 완료됩니다.

@Validated
@Tag(name = "Auth", description = "Auth 관련 Controller 입니다. 로그인 및 로그아웃, 회원가입을 담당합니다.")
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;
    private final TokenService tokenService;

    @Operation(summary = "로그인", description = "회원 아이디와 비밀번호를 가지고 로그인 후 access 토큰을 발급합니다.")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "OK",
                    content = @Content(schema = @Schema(implementation = PlayGroundResponse.class))),
            @ApiResponse(responseCode = "500", description = "INTERNAL SERVER ERROR")
    })
    @Description("Login")
    @PostMapping("/login")
    public ResponseEntity<?> login(@Valid @RequestBody AuthLoginRequestDto authLoginRequestDto, HttpServletResponse httpServletResponse) {
        // 인증처리
        AuthLoginResponseDto authLoginResponseDto = authService.login(authLoginRequestDto);

        // Token 쿠키 저장
        Cookie accessTokenCookie = new Cookie("accessToken", authLoginResponseDto.getAccessToken());
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.accessTokenExpire / 1000));
        httpServletResponse.addCookie(accessTokenCookie);

        Cookie refreshTokenCookie = new Cookie("refreshToken", authLoginResponseDto.getRefreshToken());
        refreshTokenCookie.setHttpOnly(true);
        refreshTokenCookie.setPath("/");
        refreshTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.refreshTokenExpire / 1000));
        httpServletResponse.addCookie(refreshTokenCookie);

        return PlayGroundResponse.build(authLoginResponseDto);
    }

    @Operation(summary = "로그아웃", description = "로그아웃 처리를 합니다. access token과 refresh token을 삭제합니다.")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "OK"),
            @ApiResponse(responseCode = "500", description = "INTERNAL SERVER ERROR")
    })
    @Description("Logout")
    @DeleteMapping("/logout")
    public ResponseEntity<?> logout(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) {
        String accessToken = null;

        // access / refresh Token cookie 삭제
        Cookie[] cookies = httpServletRequest.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                switch (cookie.getName()) {
                    case "accessToken":
                        accessToken = cookie.getValue();
                    case "refreshToken":
                        cookie.setValue("");
                        cookie.setPath("/");
                        cookie.setMaxAge(0);
                        httpServletResponse.addCookie(cookie);
                        break;
                }
            }
        }

        // tokens 데이터 삭제
        tokenService.deleteByAccessToken(accessToken);
        return PlayGroundResponse.ok();
    }

    @Operation(summary = "회원가입", description = "회원가입을 진행합니다.")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "OK",
                    content = @Content(schema = @Schema(implementation = PlayGroundResponse.class))),
            @ApiResponse(responseCode = "500", description = "INTERNAL SERVER ERROR")
    })
    @PostMapping("/signup")
    public ResponseEntity<?> signup(@Valid @RequestBody AuthSignUpRequestDto authSignUpRequestDto) {
        authService.signup(authSignUpRequestDto);
        return PlayGroundResponse.ok();
    }

}

3. Srping Security 설정

스프링 시큐리티 5.7버전 이후로는 WebSecurityConfigurerAdapter 방식에서 Filter Chain 방식으로 바뀌었습니다. 때문에 저는 글 작성 기준 최신 버전인 6.3.3 버전을 사용하여 Fillter chain방식으로 Spring Security Config를 구현하였습니다.

WebSecurityConfig

각 기능들에 대해서는 주석으로 설명을 다뤄두었으니 참고하여주세요.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    // All Allowed Pages
    String[] allAllowPages = {
            "/swagger-ui/**",     // Swagger UI 관련 리소스
            "/v3/api-docs/**",     // Swagger API 문서 리소스
            "/swagger-resources/**" // Swagger 추가 리소스
    };

    // Un Login User Allowed Pages
    String[] unLoginUserAllowedPages = {
            "/api/v1/auth/login", // 로그인 API,
            "/api/v1/auth/signup", // 회원가입 API
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity, JwtTokenizer jwtTokenizer) throws Exception {
        httpSecurity
                .cors(Customizer.withDefaults()) // cors 설정
                .csrf(AbstractHttpConfigurer::disable); // csrf 비활성화

        // Session 미사용
        httpSecurity.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        // 로그인 폼 비활성화
        httpSecurity.formLogin(AbstractHttpConfigurer::disable);
        // http 기본 인증(헤더) 비활성화
        httpSecurity.httpBasic(AbstractHttpConfigurer::disable);

        // 요청 URI별 권한 설정
        httpSecurity.authorizeHttpRequests((authorize) -> authorize
                // 전체 접근 허용
                .requestMatchers(allAllowPages).permitAll()
                // 로그인하지 않은 사용자 접근 허용
                .requestMatchers(unLoginUserAllowedPages).permitAll()
                // 이외의 모든 요청은 인증 정보 필요
                .anyRequest().authenticated()
        );

        // JWT 필터 사용
        httpSecurity.addFilter(this.corsFilter());
        httpSecurity.addFilterBefore(new TokenAuthenticationFilter(jwtTokenizer), UsernamePasswordAuthenticationFilter.class);

        return httpSecurity.build();
    }

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();

        config.setAllowCredentials(true);
        config.addAllowedHeader("Authorization");
        config.addAllowedHeader("access_token_key");
        config.addAllowedHeader("X-Requested-With");
        config.addAllowedHeader("Server Authorization");
        config.addAllowedHeader("Content-Type");
        config.addAllowedHeader("Content-Length");
        config.addAllowedHeader("Cache-Control");

        config.addAllowedMethod("GET");
        config.addAllowedMethod("POST");
        config.addAllowedMethod("PUT");
        config.addAllowedMethod("DELETE");
        config.addAllowedMethod("OPTIONS");
        config.addAllowedMethod("HEAD");

        config.addAllowedOriginPattern("*");

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);

        return new CorsFilter(source);
    }

    @Bean
    public StrictHttpFirewall allowUrlEncodedDoubleSlashHttpFirewall() {
        StrictHttpFirewall firewall = new StrictHttpFirewall();
        // 더블슬레시 요청 허용
        firewall.setAllowUrlEncodedDoubleSlash(true);
        return firewall;
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.httpFirewall(allowUrlEncodedDoubleSlashHttpFirewall());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

 

저는 로그인 및 회원가입을 제외한 모든 요청은 인증 및 인가처리가 되어야 실행될 수 있도록 설계하였습니다.


4. HTTP Test

IntelliJ의 Request Http 기능을 사용하여 만들어진 내용에 대해 테스트를 진행합니다.

Login Test

LogOut 테스트

security에서 설정한 것과 같이 logout은 인증 인가가 통과된 token을 사용할 때만 사용이 가능하도록 처리해두었습니다. 때문에 로그인 없이 테스트를 진행하면 아래와 같이 403 오류가 발생합니다.

때문에 로그인 후에 로그아웃 테스트를 진행하면 정상적인 로그아웃 처리가 완료된 것을 확인할 수 있습니다.