도도한 개발자
Basic Authentication을 사용해보자 🔐 본문
이 글은 세시간 동안 작성하다 ec2 인스턴스의 퍼블릭 IP를 확인하기 위해 페이지 이동하다 전부 날린 후 다시 작성하는 글입니다. 정말 많이 속상하고 화가 났지만 '앞으론 습관적으로 임시저장 버튼을 누르자'라는 교훈과 함께 마음을 다 잡고 다시 쓰렵니다...🫠
🐈⬛ 주제 선정 이유
장바구니 미션에서 Basic Authentication 사용 방법에 대해 충분히 이해하지 못하고 넘어간 것이 마음에 걸려 다시 학습했습니다. 우아한테크코스에서 소개한 인증 방법은 Basic, Session, Token 이렇게 세 가지였으나 장바구니 미션에 Basic 인증을 적용하였으므로 이번 글에선 Basic 인증이 무엇이지, 어떻게 사용하는지에 대해 다루고자 합니다.
🐈⬛ Basic 인증
Basic Authentication(이하 Basic 인증)은 Session, Token 인증 방식 대비 간단한 인증 방식입니다. 클라이언트는 사용자 이름과 비밀번호를 통해 인증할 수 있습니다.
이러한 자격 증명은 Authorization HTTP 헤더로 전송되는데 특정 형식이 만족되어야 합니다. Basic 키워드로 시작하여 base64로 인코딩된 username:password 값으로 이어집니다.
어떻게 생겼는지 볼까요? 사용자이름(username)과 HttpClient 비밀번호(password)를 통해 인증하기 위해선 다음과 같은 헤더를 보내야 합니다.
🐾 base64 인코딩
// kiara:password → a2lhcmE6cGFzc3dvcmQ=
Basic a2lhcmE6cGFzc3dvcmQ=
봐도 무엇인지 알 수 없는 'a2lhcmE6cGFzc3dvcmQ=' 이 문자열은 사실 'kiara:password' 문자열을 base64로 인코딩 한 결과입니다. 여기서 주의해야 할 점은 콜론(:) 앞 뒤로 공백이 들어가선 안된다는 것입니다. 공백이 들어가면 'a2lhcmEgOiBwYXNzd29yZA==' 처럼 아예 다른 증명으로 인식하게 되기 때문입니다.
🐈⬛ @Auth 어노테이션
💭 @Auth 어노테이션이 뭐지?
사실 이 @Auth 어노테이션은 자바에서 제공하는 어노테이션이 아니라 사용자의 인증 정보를 검증하기 위해 우리가 만들 커스텀 어노테이션입니다. 말로만 설명하는 것보다 코드를 보는 것이 이해하기 쉬울 것 같네요.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
public @interface Auth {
}
🐾 @Retention과 @Target 어노테이션
커스텀 어노테이션을 생성할 때 두 가지 설정을 해야 하는데 위 두 어노테이션이 그것에 해당합니다.
@Retention 어노테이션은 어노테이션이 선언된 대상의 메모리를 언제까지 유지할 것인지 결정합니다. 어노테이션의 파라미터로 RetentionPolicy.RUNTIME이 있으므로 해당 어노테이션은 Runtime 단계에서 메모리가 유지되고, Runtime이 종료되면 메모리도 사라집니다. 스프링의 대부분의 어노테이션은 RUNTIME으로 되어 있습니다. 실행중인 상태에서 스프링 컴포넌트 스캔이 스캔을 할 수 있어야 하기 때문이죠.
@Target 어노테이션은 해당 어노테이션을 적용할 위치를 지정합니다. 메서드가 될 수 있고 클래스, 생성자, 패키지 등 여러 선택지가 있는데 위 코드에선 ElementType으로 PARAMETER을 지정했으니 파라미터에 선언할 수 있다는 뜻이 됩니다.
🐈⬛ @Auth 어노테이션 사용
💭 @Auth 어노테이션은 어떤 경우에 사용이 되나?
우리가 @Auth 어노테이션을 만든 이유가 무엇일까요? 클라이언트 요청이 왔을 때 그 요청을 보내는 사용자의 인증 정보를 확인하고 그에 맞는 응답을 주어야 하기 때문입니다. 클라이언트가 사용자의 정보를 조회하고자 했을 때 아무한테나 정보를 줄 순 없으니까요. 그럼 이 어노테이션은 어디에 작성할까요? 클라이언트 요청을 받을 때 검증을 해야 하니 Controller에 들어갈 것 같습니다.
아래는 사용자의 정보 조회 요청에 대한 Controller 코드입니다.
@RestController
public class MemberApiController {
@GetMapping("/profile")
public ResponseEntity<ProfileResponse> findProfile(@Auth Member member) {
final int profile = member.getProfiles();
return ResponseEntity.ok(new ProfileResponse(profile));
}
}
line4의 메서드 파라미터에 @Auth 어노테이션과 그 옆에 Member 객체가 보이시나요? 해당 코드는 클라이언트가 "/profile"로 사용자의 인증 정보를 넘겨주면서 정보 조회를 요청할 때 올바른 인증 정보인지 검증한다는 뜻입니다. 그러나 아직 @Auth 어노테이션이 무엇을 어떻게 검증해야하는지 역할을 지정하지 않았습니다.
🐾 @Auth를 사용한 Basic 인증
@Auth 어노테이션은 HandlerMethodArgumentResolver를 구현한 MemberArgumentResolve에서 역할을 지정해주었습니다. 역시 말보단 코드를 보는 것이 낫겠죠?
public class MemberArgumentResolver implements HandlerMethodArgumentResolver {
private final MemberDao memberDao;
public MemberArgumentResolver(MemberDao memberDao) {
this.memberDao = memberDao;
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(Auth.class)
& parameter.getParameterType().equals(Member.class);
}
@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
String authorization = webRequest.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization == null) {
return null;
}
String[] authHeader = authorization.split(" ");
if (!authHeader[0].equalsIgnoreCase("basic")) {
return null;
}
byte[] decodedBytes = Base64.decodeBase64(authHeader[1]);
String decodedString = new String(decodedBytes);
String[] credentials = decodedString.split(":");
String email = credentials[0];
String password = credentials[1];
// 본인 여부 확인
Member member = memberDao.getMemberByEmail(email);
if (!member.checkPassword(password)) {
throw new AuthenticationException();
}
return member;
}
}
🐾 HandlerMethodArgumentResolver
HandlerMethodArgumentResolver는 컨트롤러 메소드에서 특정 조건에 맞는 파라미터가 있을 때 원하는 값을 바인딩 해주는 인터페이스입니다. HandlerMethodArgumentResolver를 상속받은 객체(MemberArgumentResolve)는 다음 두 개의 메서드를 구현해야 합니다.
public boolean supportsParameter(MethodParameter parameter);
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception;
supportsParameter() 메서드는 파라미터가 Resolver에 의해 수행될 수 있는 타입인지 아닌지 확인후 맞다면 true를, 아니면 false를 반환합니다. 만약 true를 리턴한다면 아래의 resolveArgument() 메서드를 실행합니다.
resolveArgument() 메서드는 실제 바인딩 할 객체를 반환합니다.
하나씩 볼까요?
🐾 supportsParameter()
메서드 파라미터 중에 어노테이션으로 @Auth를 포함하고 있고 파라미터 타입으로 Member 객체를 포함하는 것이 있는지 확인하고 있습니다. Controller의 findProfile() 메서드를 보면 위와 같은 조건을 만족하는 메서드라는 것을 알 수 있죠. 그러니 true를 반환하여 다음의 resolveArgument() 메서드를 수행할 수 있게 됩니다.
🐾 resolveArgument()
Basic 인증을 하기 위해선 AUTHORIZATION 헤더에 무엇이 있는지 확인하는 작업이 필요합니다. 웹요청에서 사용자 자격 증명으로 'kiara:password'를 디코딩한 'a2lhcmE6cGFzc3dvcmQ='을 전달했다고 가정해 봅시다. 이때 line21과 같이 AUTHORIZATION 헤더를 가져오면 'Basic a2lhcmE6cGFzc3dvcmQ=' 이라는 문자열을 가져올 수 있습니다. 여기서 확인해야 할 것은 두 가지 입니다.
하나, Basic 인증 방식인가?
둘, 사용자 이름에 대응하는 비밀번호인가?
순서대로 확인해봅시다.
1. Basic 인증 방식인 것을 확인하기 위해 헤더로 가져온 문자열에서 공백을 기준으로 0번째 문자열을 가져와 "basic"인지 확인합니다. 맞다면 다음 검증으로 넘어가고 아니면 적절한 인증 방식이 아니므로 null을 반환합니다.
2. 공백 기준으로 두 번째 문자열은 사용자 자격 증명을 암호화한 것이니 복호화의 과정이 필요합니다. 방법은 위의 Base64.decodeBase64() 메서드 사용 부분을 보면 될 것 같습니다.
3. 사용자 이름과 비밀번호는 콜론(:)을 기준으로 나뉘었으므로 첫 번째가 요청된 사용자 이름(위 코드에선 사용자 이메일) 정보입니다. 해당 사용자 이름을 갖고 있는 사용자를 DB에서 꺼내 요청된 사용자 비밀번호와 저장된 사용자 비밀번호를 비교해 일치하면 해당 객체를 반환하고 그렇지 않으면 인증 예외를 띄웁니다.
🐈⬛ Resolver 등록
💭 어노테이션에 역할도 부여했고, 사용 위치도 지정했으니 이제 실행하면 될까?
거의 다 왔는데 아직 한 단계 더 남았습니다.
위에서 생성한 Resolver를 스프링에 등록해야 하는데요, 스프링 부트에서 자동 구성된 스프링 MVC 구성을 조작하기 위해 WebMvcConfigurer를 구현하면 됩니다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final MemberDao memberDao;
public WebMvcConfig(MemberDao memberDao) {
this.memberDao = memberDao;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new MemberArgumentResolver(memberDao));
}
}
WebMvcConfigurer 인터페이스가 제공하는 addArgumentResolvers() 메서드를 오버라이딩하여 커스텀한 MemberArgumentResolver를 등록해줍니다.
이로써 사용자를 인증할 준비가 끝났습니다. 이제는 어느 컨트롤러든지 @Auth 어노테이션만 사용하면 사용자 인증을 할 수 있게 됩니다.
이상 Basic Authentication에 대한 글을 마칩니다. 긴 글 읽어주셔서 감사합니다🙇🏻♀️
'Backend > Java' 카테고리의 다른 글
[Java] #25. Thread(쓰레드) (0) | 2023.02.02 |
---|---|
[Java] #24. Collection(컬렉션) - Map (0) | 2023.01.20 |
[Java] #23-2. Collection(컬렉션) - Queue (0) | 2022.04.09 |
[Java] #23-1. Collection(컬렉션) - Set (0) | 2022.04.08 |
[Java] #22-2. Collection(컬렉션) - List(2) (0) | 2022.04.07 |