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 개념을 활용해보자
- 암호화하고 싶은 필드에 커스텀 어노테이션을 부여
- API 요청이 들어온 시점을 AOP 를 통해 파악
- Spring AOP 라이브러리 활용 필요
- Java reflection 을 활용하여 해당 어노테이션이 부여된 필드를 파악
- Apache Commons Lang3 라이브러리 활용 필요 (FieldUtils 클래스 활용)
- 암호화 수행
의존성 추가
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 로 생성하고 @ 를 붙여 어노테이션으로 생성
- ElementType.FIELD 를 통해 필드에 부여할 어노테이션임을 의미
- 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
반응형
'Course > Spring Security' 카테고리의 다른 글
[netplix-security-a] AuthenticationProvider 구현 (0) | 2024.10.03 |
---|---|
[netplix-security-a] PasswordEncoder 구현 (0) | 2024.10.03 |
[netplix-security-a] UserDetailsService 와 UserDetailsManager 구현 (1) | 2024.09.29 |
[netplix-security-a] UserDetails와 영속성 엔티티의 분리된 구현 (0) | 2024.09.29 |
[netplix-security-a] 기본 구성 재정의 (0) | 2024.09.29 |
댓글