🤔 인증 (Authentication) 과 인가 (Authorization) 란 ?
인증은 자원에 접근하려는 사용자가 누구인지 웹 사이트가 확인하기 위한 과정이다. 웹 사이트 혹은 어플리케이션은 현재 웹 요청을 전송하는 사용자의 정보를 확인하고 이에 맞게 요청을 처리한다.
가장 흔한 인증 방식으로는 아이디와 비밀번호 입력을 통한 로그인을 생각할 수 있다. 그리고 이 인증을 통해 사이트는 사용자를 확인할 수 있고 웹 요청에 의한 자원의 접근을 허용할 수 있다.
사용자가 자원에 접근할 수 있는 지 확인하는 과정을 인가 (Authorization) 라고 한다.
하지만 웹 요청은 웹 사이트 내에서 한 명의 사용자에 의해 빈번하게 이루어지는데, 이 때마다 사용자가 아이디와 비밀번호를 입력해야한다면 너무 번거롭지 않겠는가?
사용자 인증 이후 웹 요청을 보낼 때마다 이 사람은 인증된 사용자야 ! 라는 것을 서버가 알도록 하기 위해 다양한 인증•인가 방식이 등장하였다.
그 중 하나인 HTTP Authentication 과 HTTP Authorization 에 대해 알아보고, Spring Security 없이 Spring 기반 어플리케이션에 도입하는 방법을 알아보겠다.
🔑 HTTP Authentication / HTTP Authorization
HTTP는 기본적으로 Basic Authentication (기본 인증) 라고도 불리는 인증 방식을 제공한다. HTTP 헤더를 통해 인증 방식에 대한 정보를 클라이언트에게 제공하고, 클라이언트는 인증 정보를 HTTP 헤더에 담아 서버에 전송한다.
자세한 인증 및 인가 과정을 보자.
1. 클라이언트가 인증 정보 없이 서버에게 인증 요청을 보낸다.
2. 서버는 401 Unauthorized 상태 코드와 함께 WWW-Authenticate 헤더에 인증 방식에 대한 정보를 저장하여 전송한다.
3. 클라이언트는 인증 정보를 만들어 Authorization 헤더에 담아 서버에게 전송한다.
4. 서버는 인증 정보를 통해 사용자를 확인하고, 웹 요청을 처리한다.
클라이언트는 서버가 자신을 확인할 수 있기 위해 인증 정보를 보내야한다고 했다. 어떻게 이 인증 정보를 헤더에 담을 수 있는 형태로 만들 수 있을까? 🤔
사용자 이름과 비밀번호를 anna:password와 같이 콜론을 사용해 연결해준 뒤 Base64 방식으로 인코딩한다. 그럼 아래와 같이 문자열로 변형된 인코딩 값을 아래와 같은 형식으로 헤더에 담아 웹 요청 시 함께 실어준다.
Authorization : Basic YW5uYTpwYXNzd29yZA==
이를 전달 받은 서버는 문자열을 디코딩하여 사용자 이름과 비밀번호를 통해 인증을 할 수 있다.
👩💻 Java로 구현해보기
인증 정보가 없는 경우
먼저 클라이언트가 인증 정보가 없이 서버에 요청을 보냈을 때 어떻게 인증 정보를 보낼 수 있을 지 응답하는 것을 구현해보자. 테스트 코드를 짜면 아래와 같다.
@DisplayName("인증 정보 없이 요청하면 서버에서 401 unauthorized를 반환한다.")
@Test
void requestWithoutAuthorization() {
RestAssured.given()
.when().get("/login/member")
.then()
.statusCode(401)
.header("WWW-Authenticate", "Basic realm=\"dev server\"");
}
로그인이나 회원가입 등의 로그인이 필요 없는 경로가 아닌 경우 자원 요청을 보냈을 때 인증 정보가 없으면 401 응답을 보낸다. 인터셉트를 사용해 구현해보자. WebMvcConfigurer 인터페이스를 구현한 Configuration 클래스에서 인터셉트를 등록할 수 있다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final LoginCheckInterceptor loginCheckInterceptor;
public WebConfig(
LoginCheckInterceptor loginCheckInterceptor,
) {
this.loginCheckInterceptor = loginCheckInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginCheckInterceptor)
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/", "/signup", "/login", "/logout", "/css/**", "/*.ico", "/error", "/js/**",
"/docs/**");
}
}
인터셉터에서는 헤더에 인증 정보가 없다면 진입을 막기 위해 아래와 같이 작성한다. Authorization 헤더에 Basic으로 시작하는 문자열이 있다면 인터셉트를 통과해 로직을 처리하게 된다.
올바른 인증 정보를 보낸 경우
@DisplayName("올바른 아이디와 비밀번호를 인코딩하여 전송하면 로그인에 성공한다.")
@Test
void loginSuccess() {
saveMemberAsAnna();
String credentials = "anna@email.com" + ":" + "1234";
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes());
RestAssured.given()
.header("Authorization", "Basic " + encoded)
.when().post("/login/basic")
.then().statusCode(200);
}
클라이언트에서 올바른 아이디와 비밀번호를 인코딩한 정보를 헤더에 담아 자원 요청을 보냈다.
@RestController
public class BasicAuthController {
private final AuthService authService;
public BasicAuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login/basic")
public ResponseEntity<Void> loginBasic(@RequestHeader("Authorization") String encodedCredentials) {
String encoded = encodedCredentials.split(" ")[1];
String credentials = new String(Base64.getDecoder().decode(encoded));
String[] emailAndPassword = credentials.split(":");
authService.findByEmailAndPassword(new LoginRequest(
emailAndPassword[0], emailAndPassword[1]
));
return ResponseEntity.ok().build();
}
}
자원 요청을 받은 서버는 문자열을 디코딩하여 아이디와 비밀번호를 추출하고 데이터베이스에서 회원을 조회한다.
올바른 아이디와 비밀번호인 경우 성공 응답 (200 OK) 를 보낸다.
올바르지 않은 아이디와 비밀번호인 경우
@DisplayName("올바르지 않은 아이디와 비밀번호를 인코딩하여 전송하면 로그인에 실패한다.")
@Test
void loginFailure() {
String credentials = "anna@email.com" + ":" + "12345";
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes());
RestAssured.given()
.header("Authorization", "Basic " + encoded)
.when().post("/login/basic")
.then().statusCode(401);
}
올바르지 않은 비밀번호를 입력해 요청을 보내면 401 Unauthorized를 응답으로 보내보자.
public class AuthService {
private final MemberRepository memberRepository;
public AuthService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
public Member findByEmailAndPassword(LoginRequest loginRequest) {
return memberRepository.findFirstByEmailAndPassword(loginRequest.email(), loginRequest.password())
.orElseThrow(() -> new SecurityException(HttpStatusCode.valueOf(401), "일치하지 않는 이메일 또는 비밀번호입니다."));
}
}
요청한 아이디와 비밀번호를 데이터베이스에서 조회하였을 때 일치하지 않는다면 예외를 던졌다. 이 예외 클래스를 핸들링하여 에러 응답을 보낼 수 있도록 하면 오류를 포함한 응답을 클라이언트에게 전달할 수 있다.
@ExceptionHandler(SecurityException.class)
public ResponseEntity<SecurityErrorResponse> paymentExHandler(SecurityException exception) {
logger.error(exception.getMessage(), exception);
return ResponseEntity.status(exception.getStatusCode())
.body(new SecurityErrorResponse(exception.getMessage()));
}
참고
https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication
https://www.baeldung.com/java-base64-encode-and-decode
https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-config/interceptors.html
'우아한테크코스 > 레벨2' 카테고리의 다른 글
[Spring] Authentication 와 Authorization 을 Spring Security 없이 구현해보기 (2) - Session 편 (0) | 2024.06.30 |
---|---|
[회고] 우아한테크코스 6기 백엔드 레벨 2를 보내주며 .. 😥 (8) | 2024.06.19 |
[회고] 우아한테크코스 6기 백엔드 레벨2 9주차 회고 (라이트닝 토크, 방학식, 레벨2 돌아보기) (0) | 2024.06.18 |
[회고] 우아한테크코스 6기 백엔드 레벨2 8주차 회고 (포비와의 면담, 회복탄력성, 풋살) (0) | 2024.06.14 |
[회고] 우아한테크코스 6기 백엔드 레벨2 7주차 회고 (2) | 2024.06.03 |