
첫 번째 글에서 이어지는 두번째 글이다. 이번에는 인증과 관련된 주요 소스코드에 대한 설명을 할 예정이다.
JWT(JSON Web Token)는 JSON 형식의 데이터를 이용해 두 개체 간 정보를 안전하게 전송하기 위한 토큰이다. 이 토큰은 주로 사용자 인증 및 정보 교환에 사용되며, Header, Payload, Signature 세 부분으로 구성된다.
자가 포함 토큰(Self-contained Token): JWT는 인증에 필요한 모든 정보를 자체적으로 포함하고 있어, 서버가 별도의 저장소 없이도 클라이언트 요청을 검증할 수 있다.
무결성과 보안: JWT는 서명을 통해 데이터의 무결성을 보장한다. 이 서명은 데이터가 변조되지 않았음을 확인하며, 비밀키 또는 공개키를 사용하여 생성된다.
다양한 사용 사례: JWT는 인증 외에도 데이터 교환, 권한 부여 등 다양한 용도로 활용될 수 있다. 예를 들어, OAuth 2.0에서 액세스 토큰으로 사용되며, 사용자 권한 정보를 포함할 수 있다.
간편한 인증 처리: 서버 측에서 세션을 관리할 필요가 없기 때문에 인증 처리가 간편해진다. 클라이언트가 JWT를 서버에 전달하면, 서버는 이를 검증하고 필요한 정보를 추출한다.
확장성: 서버가 세션을 유지하지 않으므로, 서버 확장 시 추가 작업이 필요 없다. 각 서버는 독립적으로 JWT를 검증할 수 있어 상태 정보를 공유할 필요가 없다.
Cross-domain 인증: JWT는 특정 도메인에 종속되지 않으므로, 여러 도메인 간에 인증 정보를 쉽게 공유할 수 있다. 프론트와 백엔드 서버를 따로 구성한 프로젝트여서 편리하였다.
유연한 토큰 구성: JWT의 Payload 부분에 다양한 정보를 포함할 수 있어, 필요한 데이터를 손쉽게 추가할 수 있다. 이는 사용자 권한, 만료 시간 등의 정보를 포함하여 유연하게 토큰을 구성할 수 있게 해준다.
@Override
public String createAccessToken(String email, String role) {
Date now = new Date();
return JWT.create()
.withSubject(ACCESS_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod))
.withClaim(EMAIL_CLAIM, email)
.withClaim(ROLE_CLAIM, role)
.sign(Algorithm.HMAC512(secretKey));
}
@Override
public String createRefreshToken(String email) {
Date now = new Date();
return JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
.withClaim(EMAIL_CLAIM, email)
.sign(Algorithm.HMAC512(secretKey));
}
@Override
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, accessToken);
log.info("Access Token : {}", accessToken);
}
@Override
public void sendAccessTokenAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) {
response.setStatus(HttpServletResponse.SC_OK);
setAccessTokenHeader(response, accessToken);
setRefreshTokenHeader(response, refreshToken);
log.info("Access Token, RefreshToken Header Complete");
}
@Override
public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(accessHeader))
.filter(accessToken -> accessToken.startsWith(BEARER))
.map(accessToken -> accessToken.replace(BEARER, ""));
}
@Override
public Optional<String> extractRefreshToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(refreshHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
@Override
public Optional<String> extractEmail(String token) {
try {
return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build()
.verify(token)
.getClaim(EMAIL_CLAIM)
.asString());
} catch (Exception e) {
log.error("유효하지 않은 토큰입니다.");
return Optional.empty();
}
}
@Override
public void updateRefreshToken(String email, String refreshToken) {
if (memberRepository.findByEmail(email).isPresent()) {
redisService.setValues("RefreshToken" + email, refreshToken);
}
}
@Override
public boolean isTokenValid(String token) {
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
return true;
} catch (Exception e) {
log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
return false;
}
}
JwtServiceImpl 클래스는 JWT 토큰을 생성, 검증, 관리하는 기능을 제공하는 서비스 구현체이다.
createAccessToken
email, roleString)createRefreshToken
emailString)sendAccessToken
HttpServletResponse, accessTokensendAccessTokenAndRefreshToken
HttpServletResponse, accessToken, refreshTokenextractAccessToken
HttpServletRequestOptional<String>)extractRefreshToken
HttpServletRequestOptional<String>)extractEmail
tokenOptional<String>)updateRefreshToken
email, refreshTokenisTokenValid
tokenboolean)@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String IGNORE_URL = "/members/login";
private final JwtService jwtService;
private final MemberRepository memberRepository;
private final RedisService redisService;
private final GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request.getRequestURI().equals(IGNORE_URL)) {
filterChain.doFilter(request, response);
return;
}
String refreshToken = jwtService.extractRefreshToken(request)
.filter(jwtService::isTokenValid)
.orElse(null);
if (refreshToken != null) {
checkRefreshTokenAndReissueAccessToken(response, refreshToken);
return;
}
checkAccessToken(request, response, filterChain);
}
public void checkRefreshTokenAndReissueAccessToken(HttpServletResponse response, String refreshToken) {
String email = String.valueOf(jwtService.extractEmail(refreshToken));
String storedRefreshToken = redisService.getValues("RefreshToken" + email);
if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) {
throw new BadCredentialsException("Invalid refresh token");
}
memberRepository.findByEmail(email)
.ifPresent(member -> {
String reissuedRefreshToken = reissueRefreshToken(member.getEmail());
jwtService.sendAccessTokenAndRefreshToken(response, jwtService.createAccessToken(member.getEmail(), String.valueOf(member.getRole())),
reissuedRefreshToken);
});
}
private String reissueRefreshToken(String email) {
String reissuedRefreshToken = jwtService.createRefreshToken(email);
redisService.setValues("RefreshToken" + email, reissuedRefreshToken);
return reissuedRefreshToken;
}
public void checkAccessToken(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("checkAccessToken");
jwtService.extractAccessToken(request)
.filter(jwtService::isTokenValid)
.flatMap(accessToken -> jwtService.extractEmail(accessToken)
.flatMap(memberRepository::findByEmail))
.ifPresent(this::saveAuthentication);
filterChain.doFilter(request, response);
}
public void saveAuthentication(Member member) {
String password = member.getPassword();
if (password == null) {
password = getRandomPassword(10);
}
UserDetails userDetails = User.builder()
.username(member.getEmail())
.password(password)
.roles(member.getRole().name())
.build();
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetails, null,
authoritiesMapper.mapAuthorities(userDetails.getAuthorities()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private static final char[] rndAllCharacters = new char[]{
//number
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
//uppercase
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
//lowercase
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
//special symbols
'@', '$', '!', '%', '*', '?', '&'
};
public String getRandomPassword(int length) {
SecureRandom random = new SecureRandom();
StringBuilder stringBuilder = new StringBuilder();
int rndAllCharactersLength = rndAllCharacters.length;
for (int i = 0; i < length; i++) {
stringBuilder.append(rndAllCharacters[random.nextInt(rndAllCharactersLength)]);
}
return stringBuilder.toString();
}
}
JwtAuthenticationFilter 클래스는 JWT 토큰을 기반으로 인증을 처리하는 필터 클래스이다. OncePerRequestFilter를 상속받아 각 요청마다 한 번씩 필터링 작업을 수행한다.
doFilterInternal
/members/login인 경우 필터링을 건너뛴다.checkRefreshTokenAndReissueAccessToken
checkAccessToken
saveAuthentication
UserDetails 객체를 생성한다.UsernamePasswordAuthenticationToken을 생성하고, SecurityContextHolder에 인증 정보를 설정한다.getRandomPassword
이 클래스는 JWT 토큰을 기반으로 사용자를 인증하고, 필요한 경우 리프레시 토큰을 통해 새로운 액세스 토큰을 재발급하여 보안성을 유지한다. 랜덤 패스워드 같은 로직은 OAuth2.0 로그인시 따로 비밀번호를 입력 받지 않기 때문에 랜덤한 비밀번호를 데이터에 넣어 활용하기 위해 추가하였다.
OAuth2.0은 웹 애플리케이션에서 사용자 인증과 권한 부여를 구현하기 위한 표준 프로토콜이다. Spring Security를 이용하여 카카오 로그인을 활용한 OAuth2.0 구현의 주요 프로세스를 알아보자.
사용자 인증 요청:
권한 서버 인증:
인가 코드 교환:
액세스 토큰 수신:
사용자 정보 요청:
사용자 정보 수신:
인증 완료:
리디렉션:
세션 관리:
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2MemberService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberRepository memberRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
log.info("OAuth2 로그인 요청이 들어왔습니다.");
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
SocialType socialType = SocialType.KAKAO;
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
Map<String, Object> attributes = oAuth2User.getAttributes();
OAuth2Attributes extractAttributes = OAuth2Attributes.of(userNameAttributeName, attributes);
Member createdMember = getMember(extractAttributes, socialType);
return new CustomOAuth2Member(
Collections.singleton(new SimpleGrantedAuthority(createdMember.getRole().getKey())),
attributes,
extractAttributes.getNameAttributeKey(),
createdMember.getEmail(),
createdMember.getRole()
);
}
private Member getMember(OAuth2Attributes attributes, SocialType socialType) {
Member findMember = memberRepository.findBySocialTypeAndSocialId(socialType, attributes.getOAuth2MemberInfo().getId()).orElse(null);
if (findMember == null) {
return saveMember(attributes, socialType);
}
return findMember;
}
private Member saveMember(OAuth2Attributes oAuth2Attributes, SocialType socialType) {
if (memberRepository.findByEmail(oAuth2Attributes.getOAuth2MemberInfo().getEmail()).isPresent()) {
Member existingMember = memberRepository.findByEmail(oAuth2Attributes.getOAuth2MemberInfo().getEmail()).get();
SocialType existingSocialType = existingMember.getSocialType();
throw new BadCredentialsException(oAuth2Attributes.getOAuth2MemberInfo().getEmail() + "&socialType=" + existingSocialType);
}
Member createdMember = oAuth2Attributes.toEntity(socialType, oAuth2Attributes.getOAuth2MemberInfo());
return memberRepository.save(createdMember);
}
}
CustomOAuth2MemberService 클래스는 OAuth2 인증을 통해 로그인한 사용자의 정보를 처리하는 서비스 클래스이다. OAuth2UserService<OAuth2UserRequest, OAuth2User> 인터페이스를 구현하여 OAuth2 인증 요청을 처리한다.
loadUser
DefaultOAuth2UserService를 사용하여 기본 OAuth2 사용자 정보를 로드한다.OAuth2Attributes 객체를 생성한다.getMember 메소드를 호출하여 회원 정보를 가져오거나, 새로운 회원을 생성한다.CustomOAuth2Member 객체를 생성하여 반환한다.getMember
OAuth2Attributes, SocialTypeMembersaveMember 메소드를 호출하여 새로운 회원을 생성한다.saveMember
OAuth2Attributes, SocialTypeMemberBadCredentialsException 예외를 발생시킨다.OAuth2 인증을 통해 로그인한 사용자의 정보를 처리하고, 해당 사용자가 기존 회원인지 확인하거나 새로운 회원을 생성하여 저장하는 역할을 한다. 로그를 통해 OAuth2 로그인 요청을 기록하며, CustomOAuth2Member 객체를 생성하여 반환한다.
@Getter
public class OAuth2Attributes {
private final String nameAttributeKey;
private final OAuth2MemberInfo oAuth2MemberInfo;
@Builder
private OAuth2Attributes(String nameAttributeKey, OAuth2MemberInfo oAuth2MemberInfo) {
this.nameAttributeKey = nameAttributeKey;
this.oAuth2MemberInfo = oAuth2MemberInfo;
}
public static OAuth2Attributes of(String userNameAttributeName, Map<String, Object> attributes) {
return ofKakao(userNameAttributeName, attributes);
}
private static OAuth2Attributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
return OAuth2Attributes.builder()
.nameAttributeKey(userNameAttributeName)
.oAuth2MemberInfo(new KakaoOAuth2MemberInfo(attributes))
.build();
}
public Member toEntity(SocialType socialType, OAuth2MemberInfo oAuth2MemberInfo) {
return Member.builder()
.socialType(socialType)
.socialId(oAuth2MemberInfo.getId())
.email(oAuth2MemberInfo.getEmail())
.role(Role.GUEST)
.build();
}
}
OAuth2Attributes 클래스는 OAuth2 인증을 통해 얻은 사용자 정보를 처리하고, Member 엔티티로 변환하는 역할을 한다. 이 클래스는 다양한 OAuth2 제공자(Google, Kakao 등)로부터 받은 사용자 정보를 일관된 방식으로 다룬다.
필드
nameAttributeKey: 사용자 이름을 식별하는 키.oAuth2MemberInfo: OAuth2 회원 정보를 담고 있는 객체.생성자 및 빌더
@Builder 어노테이션을 통해 객체를 생성하며, nameAttributeKey와 oAuth2MemberInfo를 초기화한다.of 메소드
OAuth2Attributes 객체로 변환한다.userNameAttributeName, attributesOAuth2AttributesofKakao 메소드를 호출한다.ofKakao 메소드
OAuth2Attributes 객체로 변환한다.userNameAttributeName, attributesOAuth2AttributesnameAttributeKey를 설정한다.KakaoOAuth2MemberInfo 객체를 생성하여 oAuth2MemberInfo 필드에 설정한다.toEntity 메소드
OAuth2Attributes 객체를 Member 엔티티로 변환한다.socialType, oAuth2MemberInfoMemberMember.builder()를 사용하여 Member 객체를 생성한다.socialType, socialId, email, role을 설정하여 Member 객체를 초기화한다.@Slf4j
@Component
@RequiredArgsConstructor
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtService jwtService;
private final MemberRepository memberRepository;
private final RedisService redisService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, SecurityException {
log.info("OAuth2 로그인 성공");
CustomOAuth2Member oAuth2User = (CustomOAuth2Member) authentication.getPrincipal();
if (oAuth2User.getRole() == Role.GUEST) {
Member member = memberRepository.findByEmail(oAuth2User.getEmail()).orElseThrow(() -> new MemberException(MemberExceptionType.NOT_FOUND_MEMBER));
String accessToken = jwtService.createAccessToken(oAuth2User.getEmail(), Role.USER.getKey());
String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/members/oauth2/join")
.queryParam("email", member.getEmail())
.queryParam("accessToken", accessToken)
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
} else {
loginSuccess(request, response, oAuth2User);
}
}
private void loginSuccess(HttpServletRequest request, HttpServletResponse response, CustomOAuth2Member oAuth2User) throws IOException {
log.info("OAuth2.0 기존 사용자 로그인");
Member member = memberRepository.findByEmail(oAuth2User.getEmail()).orElseThrow(() -> new MemberException(MemberExceptionType.NOT_FOUND_MEMBER));
String accessToken = jwtService.createAccessToken(oAuth2User.getEmail(), oAuth2User.getRole().getKey());
String refreshToken = jwtService.createRefreshToken(oAuth2User.getEmail());
if (redisService.getValues("RefreshToken" + oAuth2User.getEmail()) != null && jwtService.isTokenValid(refreshToken)) {
refreshToken = redisService.getValues("RefreshToken" + oAuth2User.getEmail());
}
jwtService.updateRefreshToken(oAuth2User.getEmail(), refreshToken);
String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/login")
.queryParam("email", member.getEmail())
.queryParam("nickname", member.getNickname())
.queryParam("accessToken", accessToken)
.queryParam("refreshToken", refreshToken)
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
OAuth2LoginSuccessHandler 클래스는 OAuth2 인증이 성공적으로 완료되었을 때 호출되는 핸들러이다. SimpleUrlAuthenticationSuccessHandler를 상속받아 OAuth2 인증 성공 시 추가 작업을 처리한다.
onAuthenticationSuccess
CustomOAuth2Member 객체를 통해 인증된 사용자 정보를 가져온다.GUEST인 경우, 사용자를 추가 정보 입력 페이지로 리디렉션한다.UriComponentsBuilder를 사용하여 추가 정보 입력 페이지의 URL을 생성하고 리디렉션한다.GUEST가 아닌 경우, loginSuccess 메소드를 호출하여 기존 사용자로 로그인 처리를 한다.loginSuccess
UriComponentsBuilder를 사용하여 로그인 성공 후 리디렉션할 URL을 생성하고 리디렉션한다.OAuth2 인증이 성공적으로 완료된 후 사용자를 적절한 페이지로 리디렉션하고, 필요한 경우 JWT 토큰을 생성하여 응답 헤더에 설정한다.
@Slf4j
@Component
public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException authenticationException) throws IOException, ServletException {
String targetUrl;
if (authenticationException instanceof BadCredentialsException) {
response.getWriter().write("이미 가입된 이메일입니다.");
targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/error/already-exist")
.queryParam("email", authenticationException.getMessage())
.build()
.toUriString();
} else if (authenticationException instanceof LockedException) {
response.getWriter().write("비활성화된 계정입니다. 관리자에 문의해주세요.");
targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/error/locked")
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
} else {
response.getWriter().write("소셜 로그인에 실패하였습니다.");
targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/error")
.build()
.encode(StandardCharsets.UTF_8)
.toUriString();
}
getRedirectStrategy().sendRedirect(request, response, targetUrl);
log.info("소셜 로그인에 실패하였습니다. 에러 메시지 : {}", authenticationException.getMessage());
}
}
OAuth2LoginFailureHandler 클래스는 OAuth2 인증이 실패했을 때 호출되는 핸들러이다. SimpleUrlAuthenticationFailureHandler를 상속받아 OAuth2 인증 실패 시 추가 작업을 처리한다.
/error/already-exist 페이지로 리디렉션한다.UriComponentsBuilder를 사용하여 리디렉션 URL을 생성하고, 이메일 정보를 쿼리 파라미터로 추가한다./error/locked 페이지로 리디렉션한다.UriComponentsBuilder를 사용하여 리디렉션 URL을 생성한다./error 페이지로 리디렉션한다.UriComponentsBuilder를 사용하여 리디렉션 URL을 생성한다.getRedirectStrategy().sendRedirect(request, response, targetUrl) 메소드를 호출하여 설정된 URL로 리디렉션한다.OAuth2 인증이 실패했을 때 적절한 에러 메시지와 리디렉션 URL을 설정하여 사용자에게 알리고, 로그를 통해 실패 원인을 출력한다.
이렇게 JWT와 OAuth2.0에 관련한 주요 코드와 설명을 모두 마쳤다. 개발한 내용을 하나하나 다시 살펴보니 중간에 실수한 부분도 있고 고쳐야 할 부분도 있어, 나중에 Refactoring 관련 포스팅도 작성할 예정이다. 이렇게 조금씩 발전해나가자! 다음 포스팅에서는 일반 Login에 대한 내용으로 백엔드 프로세스를 모두 완료할 예정이다.