5 분 소요

“이돈이면” 프로젝트를 진행하면서 회원 관련 기능들을 주로 맡았는데

인증/인가 즉, 로그인 기능을 구현하면서 경험한 것들을 기록하고자 한다.


인증과 인가란?

인증은 사용자의 신원을 확인하는 과정을 말한다. 일반적으로 아이디와 비밀번호를 입력하여 로그인하는 것이 인증의 예이다.

인가는 인증된 사용자가 특정 자원에 접근할 수 있는지 확인하는 과정이다.
예를 들어, 댓글 작성자가 아닌 사용자가 댓글 수정하기를 눌렀을 때 해당 권한을 판단하는 과정이 인가의 예이다.

인증과 인가를 구현할 때, 관련된 HTTP 상태코드가 있다.

401 Unauthorized

  • 이 상태 코드는 인증이 필요함을 나타낸다.
  • 로그인하지 않은 사용자가 로그인이 필요한 API(ex. 마이 페이지에 접속)에 요청을 보냈을 때 해당 상태 코드를 내려주면 된다.

403 Forbidden

  • 요청한 자원에 대해 접근이 거부되었음을 나타낸다.
  • 본인의 리소스가 아닌 리소스에 대한 수정을 요청할 때 해당 상태코드를 내려주면 된다.

403의 경우 403 대신 404 Not Found를 응답하는 경우가 있다.
예를 들어, 개발자가 숨겨놓은 특정 페이지(관리자 페이지)에 일반 유저가 접속하려고 하는 경우
팀 컨밴션에 따라 404 응답을 보내 해당 리소스(관리자 페이지)가 있다는 정보를 일반 유저에게 감추는 것이다.


토큰과 세션

서버와 클라이언트 사이에 메시지를 전달할 때 HTTP를 사용하게 된다.

HTTP는 무상태성으로 상태를 저장하지 않는다.

즉, 사용자가 로그인을 완료하고 다른 요청을 보냈을 때 서버는 이 사용자가 로그인을 하였는지 모른다.

그렇기 때문에 사용자는 로그인 이후 특정 정보를 저장하여 매 요청마다 특정 정보를 같이 보내줘야한다.

HTTP의 무상태성속에서 로그인을 위해 사용하는 방식이 토큰(이후 JWT)과 세션 방식이다.

  • JWT?

    토큰 방식의 경우 보통 JWT(Json Web Token)를 사용하게 되는데

    JWT는 Header(헤더).Payload(내용).Signature(서명)로 구성된다.

    헤더에는 해시 알고리즘과 토큰의 타입(JWT)이 정의된다.

    내용에는 전달하려는 데이터를 포함한다.

    서명에는 헤더와 내용을 인코딩하고 유저가 지정한 비밀키를 사용하여 생성된다.

    예를 들어, 로그인한 사용자에게 Userid를 보내주고 만료기간을 3분으로 지정한다면 다음과 같은 JWT가 만들어진다.

    // Header
    {
      "alg": "HS256", // 사용된 알고리즘
      "typ": "JWT"    // 토큰 타입
    }
      
    // Payload
    {
      "id": 1, // User의 id
      "iat": 1615986933, // 토큰 발급 시간 (Issued At)
      "exp": 1615987233  // 토큰 만료 시간 (Expiration Time)
    }
      
    // Signature
    HMACSHA256(
      base64UrlEncode(header) + "." +
      base64UrlEncode(payload),
      비밀키
    ) 
    

JWT와 세션을 비교해보면 다음과 같다.

  1. 저장 위치

    • JWT: 로그인 시에 서버에서 생성되고 클라이언트에 의해 저장된다. 이후, 클라이언트 측에서 토큰을 저장하여 상태를 유지한다.
    • 세션: 서버 측에서 생성되고 서버 메모리나 데이터베이스에 저장된다. 클라이언트는 이를 식별하기위한 세션 ID를 쿠키로 저장하여 상태를 유지한다.
  2. 확장성 측면

    • JWT: 클라이언트 측에서 토큰을 보관하고 있기 때문에 확장성(Scale Out)이 높다.
    • 세션: 서버 측에서 세션을 관리하므로 확장성이 낮다.
  3. 보안적인 측면

    해킹을 당했을 때 서버에서 취할 수 있는 조치

    • JWT: 서버에서 저장하지 않기 때문에 할 수 있는 조치가 없다.
    • 세션: 세션을 제거하면 해커가 가진 세션 ID를 사용하지 못하게 할 수 있다.

3번의 경우 JWT를 두개(Access 토큰과 Refresh 토큰) 사용하여 해결할 수 있다.

  • Access 토큰과 Refresh 토큰

    Access 토큰

    • 클라이언트가 서버에 요청을 보낼 때, 인증된 사용자를 나타내기위해 사용
    • 짧은 유효기간을 가지게 설정한다.

    Refresh 토큰

    • Access 토큰이 유효기간이 만료되었을 때 새로운 Access 토큰을 발급받기 위해 사용
    • 긴 유효기간을 가지게 설정한다.

    해커가 Access 토큰을 탈취하게 되더라도 짧은 유효기간을 가지기 때문에 이후에는 접속을 할 수 없다.

하지만 위의 방식을 사용하더라도 Access 토큰을 즉시 차단할 방법이 없고 Refresh 토큰을 탈취 당할 가능성 또한 존재한다.

Refesh 토큰을 서버에 저장하면?

→ 토큰 기반 인증 방식을 사용하는 이유(stateless)가 없어져서 차라리 세션을 사용하는게 낫다.

서비스에서 사용한 인증 방식

그렇다면 이돈이면 서비스에는 어떤 방식이 맞을까

서비스에서 사용되는 아키텍처는 아래와 같다.

1-1

현재 아키텍처에서는 세션을 사용하는게 보안적인 측면이나 구현 난이도를 생각했을 때 더 낫다고 판단했다.

하지만, SPOF를 고려해서 아래와 같이 확장했을 때 어떻게 대처할 수 있을까?

1-2

세션을 사용했을 때는 요청 받은 서버에만 세션을 갖고 있게된다. 이때, 사용자가 해당 서버가 아닌 다른 서버에 세션을 보내면 사용자가 로그인 했는지 알 수 없다.

이를 해결하기 위해서는 다음과 같은 방법이 있다.

  1. 세션을 자체 구현해서 서버가 아닌 저장소(DB, Redis)에 저장한다.
  2. Sticky Session을 사용한다. (특정 유저는 특정 서버에만 요청을 보내도록)

확장했을 때라는 전제가 있었지만, 이후 1번 방식에서 Redis의 해쉬를 이용하면 어려움이 없을거라 판단하고

세션을 사용하기로 결정했다.

구현 코드

// 세션 상수
public enum SessionConst {

    USER("userId", 30 * Constants.DAY);

    private final String sessionId;
    private final int validatedTime;

    SessionConst(final String sessionId, final int validatedTime) {
        this.sessionId = sessionId;
        this.validatedTime = validatedTime;
    }

    public String getSessionId() {
        return sessionId;
    }

    public int getValidatedTime() {
        return validatedTime;
    }

    private static class Constants {

        private static final int DAY = 86400;
    }
}
  • 세션의 키로 userId를 사용하고 만료기간을 30일로 설정

요청하는 사용자마다 다른 key를 가져야 하는거 아닌가?

→ 세션은 하나의 Map이고 서버는 SessionManger를 통해 여러개의 Session을 가지며 key로 JSESSIONID를 가져서 식별하게 된다.

// 컨트롤러
@PostMapping("/login")
public ResponseEntity<Void> login(@RequestBody LoginRequest loginRequest,
                                  HttpSession session) {
    final MemberId member = authService.login(loginRequest);
    registerSession(session, member);
    return ResponseEntity.ok()
            .location(URI.create("/"))
            .build();
}

private void registerSession(final HttpSession session, final MemberId member) {
    session.setAttribute(USER.getSessionId(), member.id());
    session.setMaxInactiveInterval(USER.getValidatedTime());
}
  • 로그인을 완료한 사용자에게 세션을 동록한다.

JSESSIONID는 지정 안해도 되나?

→ 톰캣에서 자동으로 JSESSIONID를 생성하여 보내준다.

// ArgumentResolver
@Override
public boolean supportsParameter(final MethodParameter parameter) {
    return parameter.getParameterType().equals(MemberId.class)
            && parameter.hasParameterAnnotation(AuthPrincipal.class);
}
    
@Override
public Object resolveArgument(
        final MethodParameter parameter,
        final ModelAndViewContainer mavContainer,
        final NativeWebRequest webRequest,
        final WebDataBinderFactory binderFactory
) {
    final HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
    final HttpSession session = request.getSession();
    final Long userId = getUserId(session);

    if (Objects.isNull(userId)) {
        return getAnonymousMemberId(parameter);
    }

    session.setMaxInactiveInterval(USER.getValidatedTime());
    return new ActiveMemberId(userId);
}

private Long getUserId(final HttpSession session) {
    try {
        return (Long) session.getAttribute(USER.getSessionId());
    } catch (IllegalStateException e) {
        throw new EdonymyeonException(AUTHORIZATION_EMPTY);
    }
}
  • 이후 컨트롤러에 @AuthPrincipal 어노테이션이 붙은 파라미터가 있다면 위의 ArgumentResolver가 세션에서 userId 꺼내서 변환시켜준다.

소셜 로그인

소셜로그인의 등장 배경은 다음과 같다.

  • 새로운 어플리케이션에 아이디와 비밀번호를 제공하고 싶지 않음
  • 사용하려는 어플리케이션이 안전하다는 보장이 없다
  • 여러 어플리케이션에 아이디, 비밀번호를 저장하다보면 관리가 어려워짐

카카오톡을 이용한 소셜로그인(모바일 기준) 흐름은 다음 사진과 같다.

1-3

  1. 클라이언에서 카카오톡 로그인
  2. 카카오톡 인증 서버에서 클라이언트에게 Access토큰 전달
  3. 클라이언트에서 Aceess토큰 우리 서버에 전달
  4. 서비스 서버에서 Access토큰으로 카카오톡 서버에게 사용자 정보 요청
  5. 카카오톡 서버에서 서비스 서버로 정보 전달
  6. 카카오톡 사용자 정보로 서비스 서버 토큰 생성
  7. 서비스 서버 토큰 클라이언트에게 전달

구현 코드

// 컨트롤러
@PostMapping("/auth/kakao/login")
public ResponseEntity<Void> loginWithKakao(@RequestBody KakaoLoginRequest loginRequest) {
    final KakaoLoginResponse kakaoLoginResponse = kakaoAuthResponseProvider.request(loginRequest);

    final MemberResponse memberResponse = authService.findMemberByKakao(kakaoLoginResponse);
    final String basicToken = tokenGenerator.getBasicToken(memberResponse.email(), memberResponse.password());
    return ResponseEntity.ok()
            .header(HttpHeaders.AUTHORIZATION, basicToken)
            .build();
}
  • Access 토큰을 클라이언트에게 받고 처리
  • 위 과정은 세션 구현전 basic Token으로 구현된 상태
@Component
public class KakaoAuthResponseProvider {

    private static final String KAKAO_INFO_REQUEST_URL = "<https://kapi.kakao.com/v2/user/me>";

    public KakaoLoginResponse request(final KakaoLoginRequest loginRequest) {
        final RestTemplate restTemplate = new RestTemplate();
        final HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(HttpHeaders.AUTHORIZATION, "Bearer " + loginRequest.accessToken());
        httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE + ";charset=utf-8");
        final HttpEntity<Object> kakaoRequest = new HttpEntity<>(httpHeaders);

        return restTemplate.postForObject(KAKAO_INFO_REQUEST_URL, kakaoRequest, KakaoLoginResponse.class);
    }
}
  • Acces 토큰으로 카카오톡 서버에 사용자 정보 요청을 보내는 과정
@Transactional
public MemberResponse findMemberByKakao(final KakaoLoginResponse kakaoLoginResponse) {
    final SocialInfo socialInfo = SocialInfo.of(SocialType.KAKAO, kakaoLoginResponse.id());
    final Member member = memberRepository.findBySocialInfo(socialInfo)
            .orElseGet(() -> joinSocialMember(socialInfo));
    return new MemberResponse(member.getEmail(), member.getPassword());
}
  
private Member joinSocialMember(final SocialInfo socialInfo) {
	  final Member member = Member.from(socialInfo);
	  return memberRepository.save(member);
}
  • 카카오톡 서버에서 받은 사용자 정보로 데이터베이스에서 회원을 찾고 없으면 생성하는 과정

아쉬운 점

세션으로 로그인을 구현하면서 AuthArgumentResolver 에서 다른 계층을 의존하지 않도록
ActiveMemberIdMemberId 클래스로 구분하여 구현했는데 깔끔하게 인터셉터에서 인증/인가 작업을 처리 해도 좋았을 거 같다.

참고

카카오 로그인 API 문서

댓글남기기