로그인 기능의 전반적인 흐름
로그인 기능은 대표적인 3가지 방법이 소개된다.
1. Cookie를 이용한 로그인
2. Session을 이용한 로그인
3. token을 이용한 로그인
우리는 token을 이용한 로그인을 채택했다.
이유는 아래와 같다.
@RestController
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberOpenController implements MemberOpenControllerDocs {
private final MemberService memberService;
@PostMapping("/login")
public ResponseEntity<API<TokenResponse>> login(
@RequestBody @Valid final MemberLoginRequest request
) {
final API<TokenResponse> response = memberService.login(request);
return ResponseEntity.ok(response);
}
}
로그인은 Token 기능을 사용했으므로 Token Response를 응답한다.
로그인은 어떻게 보면 사용자 정보를 가져오는 것(GET)이다.
그런데 POST METHOD를 사용한 이유가 무엇일까?
GET METHOD는 저수준 모듈 -> 저수준 모듈에 의존할 수 있어야 한다고 생각했다. 즉, DB에서만 가져올 수 있는 정보라면 GET METHOD를 사용하기 충분하다고 생각한다.
@Transactional(readOnly = true)
public API<TokenResponse> login(final MemberLoginRequest request) {
final Member member = authenticateMember(request.email(), request.password());
member.validateStatus();
final TokenResponse tokenResponse = tokenService.tokenGenerator(member);
return API.of(MemberStatusType.MEMBER_LOGIN_SUCCESS, tokenResponse);
}
private Member authenticateMember(final String email, final String password) {
return memberRepository.findByEmailValue(email)
.filter(member -> encryptor.matches(password, member.getPassword()))
.orElseThrow(() -> new NotFoundException(MemberExceptionType.NOT_FOUND_ACCOUNT));
}
로그인 시 Email과 password 검증을 동시에 한다.
email 정보가 틀렸다는 예외를 주게 되면, 해커가 맞는 이메일 정보를 찾을 때까지 brute force로 확인할 수 있다. password 또한 같은 이유로 email과 password를 동시에 검증하고 Email과 Password를 다시 확인하라는 예외를 던진다.
member의 상태에 따라 휴면 회원인지 탈퇴처리 중인 회원인지에 대한 예외도 발생하는데 memberStatus는 객체의 변수이므로 member 도메인 내부에서 처리한다.
@Service
@RequiredArgsConstructor
public class TokenService {
private final AuthHelper authHelper;
private static final String KEY_MEMBER_ID = "memberId";
private static final String KEY_MEMBER_NICKNAME = "memberNickname";
private static final String KEY_MEMBER_ROLE = "memberRole";
private static final String KEY_MEMBER_IMAGE = "memberImageUrl";
public TokenResponse tokenGenerator(final Member member) {
return getTokenResponse(
createAccessTokenData(member),
createRefreshTokenData(member)
);
}
private Map<String, Object> createAccessTokenData(final Member member) {
final Map<String, Object> data = new HashMap<>();
data.put(KEY_MEMBER_ID, member.getId());
data.put(KEY_MEMBER_NICKNAME, member.getNickname());
data.put(KEY_MEMBER_ROLE, member.getRole());
data.put(KEY_MEMBER_IMAGE, member.getImageUrl());
return data;
}
private Map<String, Object> createRefreshTokenData(final Member member) {
final Map<String, Object> data = new HashMap<>();
data.put(KEY_MEMBER_ID, member.getId());
return data;
}
}
Token을 하나의 도메인으로 보고 Token Service 로직을 담고 있다. 로그인의 경우 accessToken과 refreshToken을 반환하는데 refreshToken으로 재발급한다면, 토큰만의 추가적인 역할이 있는 것이고 재사용성이나 확장을 생각해서 따로 도메인으로 분리했다.
accessToken으로 Resolver를 통해 사용자 정보를 가져오도록 하고 있다. 이 때, 클라이언트는 accessToken이 만료된다면 refreshToken을 재발급 받게된다. 그래서 재발급 시 재사용 될 수 있는 사용자 정보 관련 토큰에 대해서 관심사를 분리했다.
public interface AuthHelper {
String issueAccessToken(Map<String, Object> data);
String issueRefreshToken(Map<String, Object> data);
void validationTokenWithThrow(String token);
String resolveToken(String token);
AuthPayload parseToken(String token);
}
Token Service에서 사용하는 AuthHelper의 경우 interface로 추상화해서 구현체를 컴포넌트로 등록했다.
jjwt의 경우 version이 업그레이드 되면서 Deprecated되는 것들이 있다. 즉, jjwt 에서 추구하는 버전이 바뀌면서 우리도 AuthHelper 구현체의 코드를 바꿔야 할 수 있다. 혹은 jjwt 외에 더 좋은 토큰 관련 라이브러리가 출시 될 때 쉽게 교체할 수 있다.
로그인의 경우 정말 다양한 방식으로 구현할 수 있는 것 같다. 이번에 토큰을 사용하면서
이 두가지에 대해서 큰 난관을 느낀 것 같다.
토큰 관련 테스트는 Repository에서 확인할 수 있다.