본문 바로가기
Course/Spring Security

[netplix-security-a] AOP를 활용하여 비밀번호 암호화하기

by Lpromotion 2024. 10. 3.
Netplix 구독형 멤버십 프로젝트로 배우는 SpringSecurity
[Ch 4. PasswordEncoder] - 04. AOP를 활용하여 비밀번호 암호화하기
강의를 바탕으로 실습 내용을 정리하였습니다.

목차
 

1. 아래 암호화 요구사항은 어떻게 해결할 수 있을까?

요구사항

  • API 를 호출하는 클라이언트는 평문으로 비밀번호를 입력함
  • 보안을 위해 서버는 비밀번호를 암호화하여 관리함
  • 암호화 알고리즘은 수시로 변경될 수 있음

 

2. 암호화를 하려면 어떻게 해야 할까?

HelloRequestBody 로 평문 비밀번호를 입력 받은 다음 직접 암호화 로직을 실행시킴

  • 단점: 암호화가 필요한 시점에 매번 로직을 수행해야 한다는 점

HelloController

package fast.campus.fcss01.controller;

import fast.campus.fcss01.controller.request.HelloRequestBody;
import fast.campus.fcss01.service.EncryptService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class HelloController {

    private final EncryptService encryptService;

    @GetMapping("/api/v1/hello")
    public String hello(@RequestBody HelloRequestBody request) {
        String encrypted = encryptService.encrypt(request.getPassword());
        return "";
    }
}

EncryptService

package fast.campus.fcss01.service;

import org.springframework.stereotype.Service;

@Service
public class EncryptService {
    public String encrypt(String before) {
            // 암호화하는 로직을 여기에 작성
        return "encrypted_" + before;
    }
}

 

3. AOP 개념을 활용해보자

  1. 암호화하고 싶은 필드에 커스텀 어노테이션을 부여
  2. API 요청이 들어온 시점을 AOP 를 통해 파악
    • Spring AOP 라이브러리 활용 필요
  3. Java reflection 을 활용하여 해당 어노테이션이 부여된 필드를 파악
    • Apache Commons Lang3 라이브러리 활용 필요 (FieldUtils 클래스 활용)
  4. 암호화 수행

 

의존성 추가

AOP 와 Field 에 대한 reflection 을 수행하기 위해 아래 2개의 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.apache.commons:commons-lang3'
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

    implementation 'org.springframework.boot:spring-boot-starter-aop'
    implementation 'org.apache.commons:commons-lang3'
}

 

커스텀 어노테이션 생성

CustomEncryption 이름으로 필드 어노테이션 생성

  • annotation 패키지를 생성하고 하위에 어노테이션 생성
  • interface 로 생성하고 @ 를 붙여 어노테이션으로 생성
  1. ElementType.FIELD 를 통해 필드에 부여할 어노테이션임을 의미
  2. RetentionPolicy.RUNTIME 을 통해 런타임 시점에 동작함을 의미
package fast.campus.fcss01.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.FIELD) // 필드에 부여할 어노테이션임
@Retention(RetentionPolicy.RUNTIME) // 런타임 시점에 동작함
public @interface CustomEncryption {
}

 

어노테이션 부여

password 필드에 생성한 CustomEncryption 어노테이션 부여

package fast.campus.fcss01.controller.request;

import fast.campus.fcss01.annotation.CustomEncryption;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class HelloRequestBody {
    private String id;

    @CustomEncryption // 필드에 부여
    private String password;
}

 

Aspect 생성

API 요청이 들어온 시점을 파악하기 위해 AOP 를 활용

  • controller 패키지 아래 전체 메소드가 수행되는 시점에 Around 유형으로 Aspect 가 동작하도록 설정
package fast.campus.fcss01.aspect;

import fast.campus.fcss01.service.EncryptService;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
@RequiredArgsConstructor
public class PasswordEncryptionAspect {

    private final EncryptService encryptService;

    // 기본 패키지 아래에 controller에 들어오는 요청을 다 실행시킴
    @Around("execution(* fast.campus.fcss01.controller..*.*(..))")
    public Object passwordEncryptionAspect(ProceedingJoinPoint pjp) throws Throwable {
        return pjp.proceed();
    }
}

 

Field Reflection 과 암호화 로직

  • ProceedingJoinPoint 의 getArgs() 를 통해 요청 객체에 접근
  • Object[] 형태로 되어 있기 때문에 Arrays.stream 을 통해 stream() 으로 변경
  • 각 argument 마다 fieldEncryption (암호화 로직) 메소드를 수행
@Around("execution(* fast.campus.fcss01.controller..*.*(..))")
public Object passwordEncryptionAspect(ProceedingJoinPoint pjp) throws Throwable {
    Arrays.stream(pjp.getArgs())
            .forEach(this::fieldEncryption);

    return pjp.proceed();
}

 

  • object 값이 empty 이면 로직을 더 이상 수행하지 않음
  • apache.commons.lang3 라이브러리의 FieldUtils 활용
  • getAllFieldsList 를 통해 각 데이터 필드에 접근
  • final 이나 static 필드는 제외
  • 나머지 각 필드에 대해서는 CustomEncryption 어노테이션이 부여되어 있는지 확인
  • 어노테이션이 부여되어 있지 않다면 제외
  • String 형태가 아니라면 제외 (암호는 문자열이기 때문에)
  • EncryptService 의 encrypt 메소드를 통해 암호화 수행
  • 필드의 값을 암호화된 값으로 업데이트
public void fieldEncryption(Object object) {
    if(ObjectUtils.isEmpty(object)) {
        return;
    }

    FieldUtils.getAllFieldsList(object.getClass())
            .stream()
            .filter(filter -> !(Modifier.isFinal(filter.getModifiers()) && Modifier.isStatic(filter.getModifiers())))
            .forEach(field -> {
                try {
                    boolean encryptionTarget = field.isAnnotationPresent(CustomEncryption.class);
                    if(!encryptionTarget) {
                        return;
                    }

                    Object encryptionField = FieldUtils.readField(field, object, true);
                    if(!(encryptionField instanceof String)) {
                        return;
                    }

                    String encrypted = encryptService.encrypt((String) encryptionField);
                    FieldUtils.writeField(field, object, encrypted);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            });
}

 

테스트 코드 작성

  • @Mock 어노테이션 사용을 위해 MockitoExtension 활용
  • HelloRequestBody 에 “password” 값을 설정
  • encrypt 메소드가 호출되었을 때 “encrypted” 값이 반환되도록 설정
  • fieldEncryption 메소드가 실행되면
  • password 필드의 값은 암호화가 수행된 결과인 “encrypted” 가 되도록 테스트 수행
package fast.campus.fcss01.aspect;

import fast.campus.fcss01.controller.request.HelloRequestBody;
import fast.campus.fcss01.service.EncryptService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class PasswordEncryptionAspectTest {
    PasswordEncryptionAspect aspect;

    @Mock
    EncryptService encryptService;

    @BeforeEach
    void setup() {
        aspect = new PasswordEncryptionAspect(encryptService);
    }

    @Test
    void test() {
        // given
        HelloRequestBody requestBody = new HelloRequestBody("id", "password");
        when(encryptService.encrypt(any())).thenReturn("encrypted");

        // when
        aspect.fieldEncryption(requestBody);

        // then
        assertThat(requestBody.getPassword()).isEqualTo("encrypted");
    }
}

 

디버깅

 

Github

https://github.com/lpromotion/fcss-01/commit/b789cab1441df11b785dc7d602c9df5e178bd28c

반응형

댓글