Spring Security를 활용한 회원 로그인 처리 및 예외 처리
안녕하세요! 이번 포스팅에서는 Spring Security와 JWT(Json Web Token)를 사용하여 회원 로그인 기능을 구현하고, 다양한 예외 상황을 처리하는 방법에 대해 다뤄보겠습니다. 클라이언트는 React를 사용하였으며, 백엔드는 Spring Boot를 기반으로 합니다.
JWT(Json Web Token)는 클라이언트-서버 간의 인증을 처리하는 데 매우 유용한 도구입니다. Spring Security와 결합하면 더욱 안전하고 효율적인 인증 시스템을 구현할 수 있습니다. 이번 포스트에서는 JWT를 사용하여 안전한 회원 로그인 시스템을 구축하는 방법을 다루겠습니다. 저는 이번 ODDShop 쇼핑몰 프로젝트에 HttpOnly Cookie에 리프레쉬 토큰을 저장하고, 로컬스토리지에 엑세스 토큰을 저장하는 혼합 방식을 채택했습니다.
이전 포스팅에서는
Spring Security와 OAuth2 Client를 활용한 SPA 웹 애플리케이션 로그인 시스템 구축, (1) 구현 절차와 인증 방식에 대한 설명
클라이언트에서 href 소셜 로그인을 통해 받은 HttpOnly 리프레시 토큰으로 엑세스토큰을 발행하는 구현방법
구현 절차와 인증 방식에 대한 설명, 소셜 로그인 절차에 대해서도 이미 설명했기 때문에 생략할 것이고
가장 난감하고 힘들었던 "소셜로그인과 일반로그인 통합" 및 일반 로그인 구현 절차에 대해 최선을 다해 설명해보겠습니다.
백엔드를 배운건 2023년 4월부터이지만, 스프링 부트를 사용하기 시작한건 2024년 1월 부터 이기에
많이 부족하며, 개선할 만한 점을 발견하시면 댓글에 달아주시면 감사하겠습니다.
시큐리티에 대한 학습 내용을 노션에 정리했습니다. 들어가시면, 처음부터 끝까지 이야기를 들으실 수 있습니다.
목차
1. 클라이언트 측 요청
2. 서버 측 처리
3. 클라이언트 측 응답 처리
4. 전체 프로세스 요약
5. Backend 전체 소스 코드
6. 느낀점
1. 클라이언트 측 요청
로그인 폼 제출
사용자가 로그인 폼에 이메일과 비밀번호를 입력하고 제출 버튼을 클릭하면, 클라이언트 애플리케이션은 로그인 데이터를 포함한 POST 요청을 서버로 전송합니다.
클라이언트 측에서 로그인을 했을 때,
가입 되지 않은 이메일, 틀린 비밀번호, 인증되지 않은 가입 이메일, 서버에러, 401 인증 에러(Security 디폴트 인증에러코드)
에 대한 예외처리 응답을 확인할 수 있습니다.
const ERROR_MESSAGES = {
SERVER_ERROR: "서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.",
LOGIN_FAILED: "로그인에 실패했습니다. 다시 시도해주세요.",
NETWORK_ERROR: "네트워크 오류가 발생했습니다. 인터넷 연결을 확인해주세요.",
INVALID_CREDENTIALS: "비밀번호가 틀렸습니다.",
EMAIL_NOT_FOUND: "존재하지 않는 이메일입니다.",
};
const handleLogin = async (event) => {
event.preventDefault();
const loginData = {
email,
password,
};
try {
const response = await axios.post(
`${process.env.REACT_APP_BACKEND_URL_FOR_IMG}/login`,
loginData,
{
headers: {
Authorization: localStorage.getItem("Authorization"),
},
withCredentials: true,
}
);
const newAccessToken = response.data.accessToken;
if (parseInt(response.status) === 200) {
extractUserInfoFromAccess(newAccessToken);
setIsLoggedin(true);
const memberId = localStorage.getItem("memberId");
await Promise.all([
dispatch(fetchProfile(memberId)),
dispatch(fetchProfileImage(memberId)),
]);
setTimeout(() => {
navigate("/");
}, 200);
}
} catch (error) {
if (error.response) {
const errorMessage =
error.response.data.error || "로그인에 실패했습니다.";
swal({ title: errorMessage });
console.error("로그인 실패: ", error.response);
} else {
swal({
title: "네트워크 오류가 발생했습니다. 인터넷 연결을 확인해주세요.",
});
console.error("로그인 실패: ", error);
}
}
};
백엔드 로직이 중요하다보니, 프론트에서 회원의 프로필 데이터가 어떻게
서버로 부터 가져와져서, 어떻게 상태 관리가 되어지고
그 데이터를 JS 단에서 어떻게 처리할 것인지 ?
로그인이 되었다는 것을 어떻게 확인할 수 있는지에 대해서 깊이 있게 설명 드리긴 어려울 것 같습니다.
웹 브라우저 환경에서는 로그인 이후에
회원id, 회원role, Authentication - 로컬 스토리지
RefreshToken - Cookie (HttpOnly, Secure)
로 관리해주고 있습니다.
이후 , 회원의 프로필 데이터를 리덕스 상태관리를 통해 받아주고
프로필 데이터가 상위 컴포넌트로부터 다양한 페이지에서 쓰이고 있기 때문에
필요한 페이지에서 사용되고 있습니다.
2. 서버 측 처리
서버에서는 LoginFilter를 통해 로그인 요청을 가로채고 처리합니다.
로그인 요청 수신 및 검증
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
if(request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE) ) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
// 자바 8 이상부터, TypeReference 를 통해 원하는 형(Type)을 넣어주지 않으면 경고문이 뜸. NullPointException 등등 (ex) get("email"), readValue("meesage") 등등 . 읽어오지 못할 경우도 생기기 때문
Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, new TypeReference<>() {
});
//클라이언트 요청에서 email, password 추출
String email = usernamePasswordMap.get("email");
String password = usernamePasswordMap.get("password");
Long memberCount = memberService.countMembersByEmail(email);
log.info("memberCount = {}", memberCount);
if (memberCount == 0) {
log.info("존재하지 않는 이메일입니다: {}", email);
throw new EmailNotFoundException("No user found with this email");
} else if (memberCount > 1) {
throw new AuthenticationServiceException("There are multiple users associated with this email: " + email);
}
boolean isPasswordAuthenticated = memberService.checkPassword(email, password);
if (!isPasswordAuthenticated) {
throw new BadCredentialsException("Invalid password");
}
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password);
return this.getAuthenticationManager().authenticate(authToken);
}
일반적으로 AbstractAuthenticationProcessingFilter 에서 attemptAuthentication 는
UsernamePasswordAuthenticationToken를 생성하고 this.getAuthenticationManager().authenticate(authToken)를 호출하는 과정에서
이메일 및 비밀번호에 대한 인증 검사가 실패하면 디폴트 401 인증 에러와 함께 unsuccessfulAuthentication 로 보내집니다.
로그인에 실패했을 때 Exception 의 종류에 따라, 올바른 예외처리 로직을
unsuccessfulAuthentication 에 정의했습니다. 자세한 코드는 곧 설명드릴 예정입니다.
그리고 로그인에 성공한 후 스프링 컨텍스트 홀더에 토큰을 담는 로직을 포함하여 일련의 로그인 과정을설명드리겠습니다.
2-1. UsernamePasswordAuthenticationToken 생성 및 인증 요청
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password);
return this.getAuthenticationManager().authenticate(authToken);
이 코드는 로그인 요청이 들어왔을 때, 사용자의 이메일과 비밀번호를 기반으로 UsernamePasswordAuthenticationToken 객체를 생성하고, 이 객체를 AuthenticationManager에 전달하여 인증을 시도합니다.
- UsernamePasswordAuthenticationToken:
- Principal(사용자 이메일)과 Credentials(사용자 비밀번호)를 담고 있는 토큰입니다.
- 이 토큰은 인증 요청을 나타내며, 스프링 시큐리티의 AuthenticationManager에 의해 처리됩니다.
- this.getAuthenticationManager().authenticate(authToken):
- AuthenticationManager는 전달된 Authentication 객체를 사용하여 실제 인증을 수행합니다.
- 이 과정에서 사용자가 입력한 이메일과 비밀번호가 유효한지 확인합니다.
- AuthenticationManager는 UserDetailsService를 사용하여 사용자의 세부 정보를 로드하고, 비밀번호를 비교합니다.
2-2. UserDetailsService 및 UserDetails 인터페이스
UserDetailsService는 사용자 정보를 로드하는 역할을 합니다. 앞서 작성한 CustomUserDetailsService는 UserDetailsService를 구현한 클래스입니다
- loadUserByUsername 메서드는 이메일을 기반으로 사용자 정보를 로드합니다.
- CustomUserDetails는 UserDetails를 구현한 클래스이며, 사용자 정보 및 권한을 포함합니다.
인증 시도
AuthenticationManager를 통해 인증을 시도합니다. 이 과정에서 UserDetailsService가 사용자를 조회하고 비밀번호를 검증합니다.
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberService.findUniqueMemberByEmail(email);
if (member == null) {
throw new UsernameNotFoundException("No user found with this email");
}
CustomMemberDto customMemberDto = CustomMemberDto.createCustomMember(member);
return new CustomUserDetails(customMemberDto);
}
2-3. 인증 성공 및 실패 처리
인증 성공 처리
@Override
public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
String email = authentication.getName();
Member member = memberService.memberLogin(email);
String memberId = member.getId().toString();
String role = member.getMemberRole().toString();
String newAccess = jwtUtil.createAccessToken("access", memberId, role);
String newRefresh = jwtUtil.createRefreshToken("refresh", memberId, role);
refreshService.saveOrUpdateRefreshEntity(member, newRefresh);
addResponseDataV3(response, newAccess, newRefresh, email);
}
보통 로그인에 성공하고나서, SecurityContextHolder 에 authentication를 담아서, 로그인을 처리하지만
저는 보안을 더 높이고, 안전하게 처리하기 위해서
JWTFilter 필터단에서 클라이언트로부터 받은 Refresh, Access 토큰의 유효성 검사를 통과한 경우
SecurityContextHolder 에 authentication를 담아서, 로그인을 처리하도록 필터를 구현했습니다.
JWTFilterV3
@Slf4j
@RequiredArgsConstructor
public class JWTFilterV3 extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
private final CookieService cookieService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// request에서 Authorization 헤더 찾음
String authorization = request.getHeader("Authorization");
// 쿠키에서 "refreshAuthorization" 값을 가져 옴
String refreshAuthorization = cookieService.getRefreshAuthorization(request);
if (refreshAuthorization == null) {
filterChain.doFilter(request, response);
return;
}
String refreshToken = Objects.requireNonNull(refreshAuthorization).substring(7);
log.info("Id : " + jwtUtil.getMemberId(refreshToken) + " 유저가 로그인 했습니다.");
// 현재 시각을 "년-월-일"으로
LocalDateTime now = LocalDateTime.now();
String currentDate = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
// refreshAuthorization 쿠키 검증
if(!refreshAuthorization.startsWith("Bearer+")){
log.warn(" 로그인 하지 않은 상태이거나, refreshAuthorization 을 Request Header에 담아주지 않았습니다. ");
log.warn(" now : " + currentDate);
// 토큰이 유효하지 않으므로 request와 response를 다음 필터로 넘겨줌
filterChain.doFilter(request, response);
// 메서드 종료
return;
}
// accessToken 유효기간이 만료한 경우 메서드 종료. API 사용 시, Request Header 에 Authorization 을 담아주는 상황
if (authorization != null && authorization.startsWith("Bearer ")) {
String accessToken = authorization.split(" ")[1];
if(jwtUtil.isExpired(accessToken)){
String memberId = jwtUtil.getMemberId(accessToken);
log.warn("access token 이 만료되었습니다.");
if (memberId != null) {
log.warn("memberId : " + memberId + " now : " + currentDate);
}
filterChain.doFilter(request, response);
// 메서드 종료
return;
}
}
// access 에 있는 username, role 을 통해 Authentication 사용자 정보를
Authentication authToken = getAuthentication(refreshToken);
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
private Authentication getAuthentication(String token) {
if (!jwtUtil.validateToken(token)) {
// 토큰이 유효하지 않을 경우 예외 처리
throw new BadCredentialsException("유효하지 않은 토큰입니다.");
}
String memberId = jwtUtil.getMemberId(token);
MemberRole role = jwtUtil.getRole(token);
CustomMemberDto customMemberDto = CustomMemberDto.createCustomMember(Long.valueOf(memberId), role, true);
CustomUserDetails customOAuth2User = new CustomUserDetails(customMemberDto);
return new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities());
}
}
JWTFilter 에서의 기능 설명
이 JWT 필터는 Spring Security에서 제공하는 OncePerRequestFilter를 확장하여 구현되었습니다. 이 필터는 모든 HTTP 요청에 대해 한 번 실행됩니다. 필터의 주요 역할은 JWT 토큰을 검사하고 유효한 경우 인증을 설정하는 것입니다.
전체적인 인증 흐름
- JWT 토큰 검사
- 요청 헤더에서 Authorization 헤더를 찾습니다.
- 쿠키에서 refreshAuthorization 값을 찾습니다.
- 토큰 유효성 검사
- refreshAuthorization 쿠키가 존재하지 않거나 "Bearer+"로 시작하지 않는 경우, 요청을 다음 필터로 넘겨주고 메서드를 종료합니다.
- Authorization 헤더에 포함된 access 토큰의 유효성을 검사합니다. 만료된 경우 요청을 다음 필터로 넘겨주고 메서드를 종료합니다.
- 인증 설정
- 유효한 refresh 토큰이 있으면, getAuthentication 메서드를 호출하여 Authentication 객체를 생성합니다.
- SecurityContextHolder에 Authentication 객체를 설정하여 현재 요청에 대해 인증된 사용자로 설정합니다.
주요 포인트
- 클라이언트로부터 JWT 토큰 수신 및 검증
- 클라이언트로부터 Authorization 헤더와 쿠키에서 refreshAuthorization 값을 가져옵니다.
- refreshAuthorization 쿠키가 "Bearer+"로 시작하지 않으면, 인증되지 않은 상태로 요청을 처리합니다.
- 만료된 Access 토큰 처리
- Access 토큰이 만료된 경우, 로그 경고를 출력하고 인증되지 않은 상태로 요청을 처리합니다.
- SecurityContextHolder에 인증 설정
- 유효한 Refresh 토큰이 있으면 getAuthentication 메서드를 호출하여 Authentication 객체를 생성합니다.
- SecurityContextHolder에 Authentication 객체를 설정하여 현재 요청에 대해 인증된 사용자로 설정합니다.
getAuthentication 메서드
이 메서드는 Refresh 토큰을 사용하여 인증 정보를 생성합니다.
private Authentication getAuthentication(String token) {
if (!jwtUtil.validateToken(token)) {
throw new BadCredentialsException("유효하지 않은 토큰입니다.");
}
String memberId = jwtUtil.getMemberId(token);
MemberRole role = jwtUtil.getRole(token);
CustomMemberDto customMemberDto = CustomMemberDto.createCustomMember(Long.valueOf(memberId), role, true);
CustomUserDetails customOAuth2User = new CustomUserDetails(customMemberDto);
return new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities());
}
이 메서드는 다음과 같은 역할을 합니다:
- 토큰이 유효한지 검사합니다.
- 토큰에서 사용자 ID와 역할을 추출합니다.
- 사용자 ID와 역할을 포함하는 CustomMemberDto 객체를 생성합니다.
- CustomUserDetails 객체를 생성하고, 이를 사용하여 UsernamePasswordAuthenticationToken을 생성합니다.
인증 실패 처리
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.info("Login failed: " + failed.getMessage());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
JsonObject responseData = new JsonObject();
if (failed instanceof BadCredentialsException) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
responseData.addProperty("error", "Invalid password");
} else if (failed instanceof UsernameNotFoundException) {
response.setStatus(HttpStatus.BAD_REQUEST.value());
responseData.addProperty("error", "No user found with this email");
} else {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
responseData.addProperty("error", "Authentication failed");
}
response.getWriter().write(responseData.toString());
super.unsuccessfulAuthentication(request, response, failed);
}
로그인에 실패했을 때에는,
BadCredentialsException 를 통해서 401 코드와 함께 메세지를 담아주었고
UsernameNotFoundException 를 통해서 400 코드와 함께 메세지를 담아주었습니다.
이 메세지는 클라이언트에게 전달되어, 적절한 안내 메세지를 보여줍니다.
3. 클라이언트 측 응답 처리
클라이언트는 서버로부터의 응답을 수신한 후, 성공 또는 실패 메시지를 사용자에게 표시합니다.
const ERROR_MESSAGES = {
SERVER_ERROR: "서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.",
LOGIN_FAILED: "로그인에 실패했습니다. 다시 시도해주세요.",
NETWORK_ERROR: "네트워크 오류가 발생했습니다. 인터넷 연결을 확인해주세요.",
INVALID_CREDENTIALS: "비밀번호가 틀렸습니다.",
EMAIL_NOT_FOUND: "존재하지 않는 이메일입니다.",
};
4. 전체적인 로그인 프로세스 요약
- 사용자가 이메일과 비밀번호를 입력하여 로그인 요청을 보냅니다.
- LoginFilter에서 UsernamePasswordAuthenticationToken를 생성하고 AuthenticationManager에 전달하여 인증을 시도합니다.
- CustomUserDetailsService에서 이메일을 기반으로 사용자 정보를 로드하고, 비밀번호를 확인합니다.
- 인증이 성공하면 successfulAuthentication 메서드가 호출되어 JWT 토큰을 생성하고 응답에 포함시킵니다. 또한 SecurityContextHolder를 통해 인증 정보를 스프링 시큐리티 컨텍스트에 설정합니다.
- 인증이 실패하면 unsuccessfulAuthentication 메서드가 호출되어 실패 원인에 따른 적절한 응답을 설정합니다.
이렇게 함으로써 사용자의 로그인 요청을 처리하고, 인증된 사용자 정보를 유지하여 이후의 요청에 사용할 수 있습니다.
이와 같이 저는 Spring Security와 JWT를 이용한 회원 로그인 기능을 구현할 수 있었습니다.
각 단계별로 예외 처리를 통해 보다 견고한 로그인 시스템을 구축할 수 있습니다.
만약에 시큐리티가 기본적인 디폴트로 제공하는 401 인증 에러가 아닌, 다른 에러에 대한 커스텀을 진행하고 싶으시다면
AuthenticationEntryPoint 를 커스텀해서 , SecurityConfiguration 에 등록하는 방법에 대해 구글링 해보시면 됩니다.
5. Backend 전체 소스 코드
LoginFilter
package PU.pushop.global.authentication.jwts.filters;
import PU.pushop.global.authentication.jwts.utils.JWTUtil;
import PU.pushop.members.entity.Member;
import PU.pushop.members.service.MemberService;
import PU.pushop.members.service.RefreshService;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.gson.JsonObject;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.util.StreamUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import static PU.pushop.global.authentication.jwts.utils.CookieUtil.createCookie;
@Slf4j
public class LoginFilter extends CustomJsonEmailPasswordAuthenticationFilter {
private final MemberService memberService;
private final JWTUtil jwtUtil;
private final RefreshService refreshService;
private final ObjectMapper objectMapper;
private static final String CONTENT_TYPE = "application/json"; // JSON 타입의 데이터로 오는 로그인 요청만 처리
public LoginFilter(AuthenticationManager authenticationManager, ObjectMapper objectMapper, MemberService memberService, JWTUtil jwtUtil, RefreshService refreshService, ObjectMapper objectMapper1) {
super(authenticationManager, objectMapper);
this.memberService = memberService;
this.jwtUtil = jwtUtil;
this.refreshService = refreshService;
this.objectMapper = objectMapper1;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
if(request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE) ) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
// 자바 8 이상부터, TypeReference 를 통해 원하는 형(Type)을 넣어주지 않으면 경고문이 뜸. NullPointException 등등 (ex) get("email"), readValue("meesage") 등등 . 읽어오지 못할 경우도 생기기 때문
Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, new TypeReference<>() {
});
//클라이언트 요청에서 email, password 추출
String email = usernamePasswordMap.get("email");
String password = usernamePasswordMap.get("password");
Long memberCount = memberService.countMembersByEmail(email);
log.info("memberCount = {}", memberCount);
if (memberCount == 0) {
log.info("존재하지 않는 이메일입니다: {}", email);
throw new EmailNotFoundException("No user found with this email");
} else if (memberCount > 1) {
throw new AuthenticationServiceException("There are multiple users associated with this email: " + email);
}
boolean isPasswordAuthenticated = memberService.checkPassword(email, password);
if (!isPasswordAuthenticated) {
throw new BadCredentialsException("Invalid password");
}
// Principal(인증-유저이메일), Credentials(권한), Authenticated 등의 정보
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password);
// log.info(String.valueOf(authToken.toString()));
return this.getAuthenticationManager().authenticate(authToken);
}
@Override
// 로그인 성공 시 실행하는 메소드 (여기서 JWT를 발급하면 됨)
public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
String email = authentication.getName();
Member member = memberService.memberLogin(email);
// 액세스, 리프레쉬 토큰 생성 시 id, role 필요
String memberId = member.getId().toString();
String role = member.getMemberRole().toString();
// 토큰 종류(카테고리), 유저이름, 역할 등을 페이로드에 담는다.
String newAccess = jwtUtil.createAccessToken("access", memberId, role);
String newRefresh = jwtUtil.createRefreshToken("refresh", memberId, role);
// [Refresh 토큰 - DB 에서 관리합니다.] 리프레쉬 토큰 관리권한이 서버에 있습니다.
refreshService.saveOrUpdateRefreshEntity(member, newRefresh);
// [response.data] 에 Json 형태로 accessToken 과 refreshToken 을 넣어주는 방식
addResponseDataV3(response, newAccess, newRefresh, email);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
log.info("로그인에 실패했습니다. 실패 원인: {}", failed.getMessage());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
Map<String, String> responseData = new HashMap<>();
if (failed instanceof BadCredentialsException) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
responseData.put("error", "Invalid password");
} else if (failed instanceof EmailNotFoundException) {
response.setStatus(HttpStatus.BAD_REQUEST.value());
responseData.put("error", "No user found with this email");
} else {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
responseData.put("error", "Authentication failed");
}
// response.getWriter().write(responseData.toString());
response.getWriter().write(objectMapper.writeValueAsString(responseData));
// super.unsuccessfulAuthentication(request, response, failed);
}
/**
*
* 쿠키에 refreshToken 을 넣어주는 방식
*/
private void addResponseDataV3(HttpServletResponse response, String accessToken, String refreshToken, String email) throws IOException {
// 액세스 토큰을 JsonObject 형식으로 응답 데이터에 포함하여 클라이언트에게 반환
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
// JSON 객체를 생성하고 액세스 토큰을 추가
JsonObject responseData = new JsonObject();
responseData.addProperty("accessToken", accessToken);
response.getWriter().write(responseData.toString());
// 리프레시 토큰을 쿠키에 저장
response.addCookie(createCookie("refreshAuthorization", "Bearer+" +refreshToken));
// HttpStatus 200 OK
response.setStatus(HttpStatus.OK.value());
}
public static class EmailNotFoundException extends AuthenticationException {
public EmailNotFoundException(String message) {
super(message);
}
}
// 사용자의 권한 정보를 가져옴
private String extractAuthority(Authentication authentication) {
return authentication.getAuthorities().stream()
.findFirst()
.map(GrantedAuthority::getAuthority)
.orElse("ROLE_USER"); // 기본 권한 설정. [따로 설정하지 않았을때]
}
/**
* 로그인 성공시 -> [reponse Header] : Access Token 추가, [reponse Cookie] : Refresh Token 추가
*/
private void setTokenResponseV1(HttpServletResponse response, String accessToken, String refreshToken) {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
// [reponse Header] : Access Token 추가
response.addHeader("Authorization", "Bearer " + accessToken);
// [reponse Cookie] : Refresh Token 추가
response.addCookie(createCookie("RefreshToken", refreshToken));
// HttpStatus 200 OK
response.setStatus(HttpStatus.OK.value());
}
/**
* [response.data] 에 Json 형태로 accessToken 을 넣어주고, 쿠키에 refreshToken 을 넣어주는 방식
*/
private void addResponseDataV2(HttpServletResponse response, String accessToken, String refreshToken, String email) throws IOException {
// 액세스 토큰을 JsonObject 형식으로 응답 데이터에 포함하여 클라이언트에게 반환
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
// response.data 에 accessToken, refreshToken 담아주기.
JsonObject responseData = new JsonObject();
responseData.addProperty("accessToken", accessToken);
responseData.addProperty("refreshToken", refreshToken);
response.getWriter().write(responseData.toString());
// HttpStatus 200 OK
response.setStatus(HttpStatus.OK.value());
}
}
CustomUserDetailsService
package PU.pushop.global.authentication.jwts.service;
import PU.pushop.global.authentication.jwts.entity.CustomUserDetails;
import PU.pushop.global.authentication.jwts.entity.CustomMemberDto;
import PU.pushop.members.entity.Member;
import PU.pushop.members.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberService memberService;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberService.findUniqueMemberByEmail(email);
if (member == null) {
throw new UsernameNotFoundException("No user found with this email: " + email);
}
CustomMemberDto customMemberDto = CustomMemberDto.createCustomMember(member);
return new CustomUserDetails(customMemberDto);
}
}
CustomMemberDto
package PU.pushop.global.authentication.jwts.entity;
import PU.pushop.members.entity.Member;
import PU.pushop.members.entity.enums.MemberRole;
import lombok.Data;
@Data
public class CustomMemberDto {
private Long memberId;
private String email;
private String username;
private String password;
private MemberRole memberRole;
private boolean isActive;
public CustomMemberDto(Long memberId, String email, String username, String password, MemberRole memberRole, boolean isActive) {
this.memberId = memberId;
this.email = email;
this.username = username;
this.password = password;
this.memberRole = memberRole;
this.isActive = isActive;
}
public static CustomMemberDto createCustomMember(Member member) {
return new CustomMemberDto(member.getId(), member.getEmail(), member.getUsername(), member.getPassword(), member.getMemberRole(), member.getIsActive());
}
public static CustomMemberDto createCustomMember(Long memberId, Member member, MemberRole role, boolean isActive) {
return new CustomMemberDto(memberId, member.getEmail(), member.getUsername(), member.getPassword(), role, isActive);
}
public static CustomMemberDto createCustomMember(Long memberId, MemberRole role, boolean isActive) {
return new CustomMemberDto(memberId, null, null, null, role, isActive);
}
}
MemberRole
package PU.pushop.members.entity.enums;
public enum MemberRole {
USER, ADMIN, SELLER, ETC
}
CustomUserDetails
package PU.pushop.global.authentication.jwts.entity;
import PU.pushop.members.entity.enums.MemberRole;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.ArrayList;
import java.util.Collection;
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final CustomMemberDto customMemberDto;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add((GrantedAuthority) () -> customMemberDto.getMemberRole().toString());
return collection;
}
@Override
public String getPassword() {
return customMemberDto.getPassword();
}
@Override
public String getUsername() {
return customMemberDto.getEmail();
}
public Long getMemberId() { return customMemberDto.getMemberId(); }
public MemberRole getMemberRole(){
return customMemberDto.getMemberRole();
}
@Override
public boolean isAccountNonExpired() {
return customMemberDto.isActive();
}
@Override
public boolean isAccountNonLocked() {
return customMemberDto.isActive();
}
@Override
public boolean isCredentialsNonExpired() {
return customMemberDto.isActive();
}
@Override
public boolean isEnabled() {
return customMemberDto.isActive();
}
}
MemberService
package PU.pushop.members.service;
import PU.pushop.global.authentication.jwts.filters.LoginFilter;
import PU.pushop.global.authentication.jwts.utils.CookieUtil;
import PU.pushop.members.entity.Member;
import PU.pushop.members.entity.Refresh;
import PU.pushop.members.repository.MemberRepositoryV1;
import PU.pushop.members.repository.RefreshRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.ResponseStatus;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@Slf4j
public class MemberService {
private final MemberRepositoryV1 memberRepositoryV1;
private final BCryptPasswordEncoder passwordEncoder;
private final RefreshRepository refreshRepository;
@Transactional
public Member joinMember(Member member) {
Member newMember = Member.createGeneralMember(
member.getEmail(),
member.getNickname(),
member.getPassword(),
member.getToken(),
member.getSocialId()
);
newMember.activateMember();
return memberRepositoryV1.save(newMember);
}
@Transactional // 회원의 LastLoginAt 를 수정하기 때문에, readOnly 로 설정하면 안됩니다.
public Member memberLogin(String email) throws BadCredentialsException {
Long countedMembersByEmail = countMembersByEmail(email);
if (countedMembersByEmail == 1) {
Member member = findUniqueMemberByEmail(email);
updateLastLoginAt(member);
return member;
} else {
throw new BadCredentialsException("There are multiple users associated with this email");
}
}
public boolean checkPassword(String email, String password) {
Member requestMember = memberRepositoryV1.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("해당 이메일이 존재하지 않습니다."));
return passwordEncoder.matches(password, requestMember.getPassword());
}
@Transactional
public ResponseEntity<?> memberLogout(String refreshAuthorization, HttpServletRequest request, HttpServletResponse response) {
// refreshAuthorization 쿠키가 null 이거나 비어 있는지 확인
if (refreshAuthorization == null || refreshAuthorization.isEmpty()) {
log.warn("로그아웃 요청에 refreshAuthorization 쿠키가 없습니다.");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("로그아웃 요청에 refreshAuthorization 쿠키가 없습니다.");
}
// refreshAuthorization 쿠키가 올바른 형식을 갖추었는지 확인
if (!refreshAuthorization.startsWith("Bearer+")) {
log.warn("잘못된 형식의 refreshAuthorization 쿠키입니다.");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("잘못된 형식의 refreshAuthorization 쿠키입니다.");
}
String refreshToken = refreshAuthorization.substring(7);
// refreshToken 이 비어 있는지 확인
if (refreshToken.isEmpty()) {
log.warn("refreshToken is Empty.");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("refreshToken is Empty.");
}
Optional<Refresh> optionalRefresh = refreshRepository.findByRefreshToken(refreshToken);
if (optionalRefresh.isPresent()) {
Refresh refreshEntity = optionalRefresh.get();
Member member = memberRepositoryV1.findById(refreshEntity.getMember().getId())
.orElseThrow(() -> new UsernameNotFoundException("id에 맞는 해당 회원이 존재하지 않습니다."));
// Response refresh Cookie 삭제
CookieUtil.deleteCookie(response, "refreshAuthorization");
// DB 에 있는 refresh 삭제
refreshRepository.delete(refreshEntity);
log.info("멤버 Id : " + member.getId() + " 님이 로그아웃 하셨습니다.");
return ResponseEntity.status(HttpStatus.OK).body("멤버 Id : " + member.getId() + " 님이 로그아웃 하셨습니다.");
} else {
log.warn("DB 에 존재하지 않은 잘못된 Refresh token 입니다. 다른 유저의 토큰입니다. Refresh token : " + refreshToken);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("DB 에 존재하지 않은 잘못된 Refresh token 입니다. 다른 유저의 토큰입니다.");
}
}
public Member findUniqueMemberByEmail(String email) {
List<Member> members = memberRepositoryV1.findAllByEmail(email);
if (members.size() > 1) {
throw new AuthenticationServiceException("There are multiple users associated with this email: " + email);
} else if (members.size() == 1) {
return members.get(0);
} else {
throw new LoginFilter.EmailNotFoundException("No user found with this email: " + email); // 변경된 부분
}
}
public Long countMembersByEmail(String email) {
return memberRepositoryV1.countByEmail(email);
}
private void updateLastLoginAt(Member member) {
member.setLastLoginDate(LocalDateTime.now());
memberRepositoryV1.save(member);
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
public static class ExistingMemberException extends IllegalStateException {
public ExistingMemberException() {
super("이미 존재하는 회원입니다.");
}
}
public static class MultipleUsersFoundException extends RuntimeException {
public MultipleUsersFoundException(String message) {
super(message);
}
}
public static class UserNotFoundByEmailException extends RuntimeException {
public UserNotFoundByEmailException(String message) {
super(message);
}
}
public String maskName(String name) {
int length = name.length();
if (length == 2) {
return name.charAt(0) + "*";
} else if (length == 3) {
return name.charAt(0) + "*" + name.charAt(2);
} else if (length >= 4) {
StringBuilder maskedName = new StringBuilder();
maskedName.append(name.charAt(0));
maskedName.append("*".repeat(length - 2));
maskedName.append(name.charAt(length - 1));
return maskedName.toString();
}
return name;
}
}
JWTUtil
package PU.pushop.global.authentication.jwts.utils;
import PU.pushop.members.entity.enums.MemberRole;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Base64;
import java.util.Date;
@Component
@Slf4j
public class JWTUtil {
private SecretKey secretKey; // 'final' 제거
@Value("${spring.jwt.secret}")
private String jwtSecret;
private static final String MEMBERPK_CLAIM_KEY = "memberId";
private static final String CATEGORY_CLAIM_KEY = "category";
private Long accessTokenExpirationPeriod = 60L * 30; // 30 분
private Long refreshTokenExpirationPeriod = 3600L * 24 * 7; // 7일
// public JWTUtil() {
// this.secretKey = Jwts.SIG.HS256.key().build();
// }
@PostConstruct
public void init() {
byte[] keyBytes = Base64.getDecoder().decode(jwtSecret);
this.secretKey = Keys.hmacShaKeyFor(keyBytes);
}
private Claims parseToken(String token) {
// Check if token is null or empty
if (token == null || token.trim().isEmpty()) {
throw new IllegalArgumentException("Token cannot be null or empty");
}
try {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token).getPayload();
} catch (JwtException e) {
throw new RuntimeException("Failed to parse JWT token", e);
}
}
public String getMemberId(String token) {
return parseToken(token).get(MEMBERPK_CLAIM_KEY, String.class);
}
public String getCategory(String token) {
return parseToken(token).get(CATEGORY_CLAIM_KEY, String.class);
}
public MemberRole getRole(String token) {
return MemberRole.valueOf(parseToken(token).get("role", String.class));
}
public Boolean isExpired(String token) {
// Check if token is null or empty
if (token == null || token.trim().isEmpty()) {
throw new IllegalArgumentException("Token cannot be null or empty");
}
// Validate token structure
if (!validateToken(token)) {
throw new IllegalArgumentException("Token is not valid");
}
// Check expiration
return parseToken(token).getExpiration().before(new Date());
}
private String createToken(String category, String memberId, String role, Date expirationDate) {
return Jwts.builder()
.claim(CATEGORY_CLAIM_KEY, category)
.claim(MEMBERPK_CLAIM_KEY, memberId)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(expirationDate)
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
}
public String createAccessToken(String category, String memberId, String role) {
// 30 분
LocalDateTime expirationDateTime = LocalDateTime.now().plusSeconds(accessTokenExpirationPeriod);
Date expirationDate = Date.from(expirationDateTime.atZone(ZoneId.systemDefault()).toInstant());
return createToken(category, memberId, role, expirationDate);
}
public String createRefreshToken(String category, String memberId, String role) {
// 7 일
LocalDateTime expirationDateTime = LocalDateTime.now().plusSeconds(refreshTokenExpirationPeriod);
Date expirationDate = Date.from(expirationDateTime.atZone(ZoneId.systemDefault()).toInstant());
return createToken(category, memberId, role, expirationDate);
}
/**
* Validates a JWT token.
*
* @param token The JWT token to validate.
* @return true if the token is valid, false otherwise.
*/
public boolean validateToken(String token) {
try {
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
return true;
} catch (MalformedJwtException ex) {
log.error("Malformed JWT token", ex);
return false;
} catch (ExpiredJwtException ex) {
log.error("Expired JWT token", ex);
return false;
} catch (UnsupportedJwtException ex) {
log.error("Unsupported JWT token", ex);
return false;
} catch (IllegalArgumentException ex) {
log.error("Empty JWT token", ex);
return false;
} catch (JwtException ex) {
log.error("Failed to validate JWT token", ex);
return false;
}
}
}
RefreshService
package PU.pushop.members.service;
import PU.pushop.members.entity.Member;
import PU.pushop.members.entity.Refresh;
import PU.pushop.members.model.RefreshDto;
import PU.pushop.members.repository.RefreshRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class RefreshService {
private final RefreshRepository refreshRepository;
private static final Long refreshTokenExpirationPeriod = 3600L * 24 * 7; // 7일
/**
* [Refresh 토큰 - DB에서 관리합니다.] 리프레쉬 토큰 관리권한이 서버에 있습니다.
* 로그인에 성공했을 때, 이미 가지고 있던 리프레쉬 토큰 or 처음 로그인한 유저에 대해 리프레쉬 토큰을 DB에 업데이트합니다.
* @param member 회원의 PK로, member의 refresh Token를 조회.
* @param newRefreshToken
*/
public void saveOrUpdateRefreshEntity(Member member, String newRefreshToken) {
// 멤버의 PK 식별자로, refresh 토큰을 가져옵니다.
Optional<Refresh> existedRefresh = refreshRepository.findByMemberId(member.getId());
LocalDateTime expirationDateTime = LocalDateTime.now().plusSeconds(refreshTokenExpirationPeriod);
// 로그인 이메일과 같은 이메일을 가지고 있는 Refresh 엔티티에 대해서, refresh 값을 새롭게 업데이트해줌
if (existedRefresh.isPresent()) {
Refresh refreshEntity = existedRefresh.get();
// Dto 를 통해서, 새롭게 생성한 RefreshToken 값, 유효기간 등을 받아줍니다.
RefreshDto refreshDto = RefreshDto.createRefreshDto(newRefreshToken, expirationDateTime);
// Dto 정보들로 기존에 있던 Refresh 엔티티를 업데이트 후 저장합니다.
refreshEntity.updateRefreshToken(refreshDto);
refreshRepository.save(refreshEntity);
} else {
// 완전히 새로운 리프레시 토큰을 생성 후 저장
Refresh newRefreshEntity = new Refresh(member, newRefreshToken, expirationDateTime);
refreshRepository.save(newRefreshEntity);
}
}
}
RefreshRepository
package PU.pushop.members.repository;
import PU.pushop.members.entity.Refresh;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
public interface RefreshRepository extends JpaRepository<Refresh, Long> {
Boolean existsByRefreshToken(String refresh);
@Transactional
void deleteByRefreshToken(String refresh);
Optional<Refresh> findByRefreshToken(String refresh);
Optional<Refresh> findByMemberId(Long id);
}
ObjectMapper - Bean 으로 등록하여, 직렬화와 역직렬화에 사용됩니다
package PU.pushop.global.authentication.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ObjectMapperConfig {
@Bean
public ObjectMapper objectMapper(){
return new ObjectMapper();
}
}
6. 느낀점
1. Href 하이퍼링크를 통한, 백엔드 소셜로그인을 구현시 HttpOnly 리프레쉬 토큰 Cookie 를 처리하는 방법에 대한 어려움.
로그인 로직(Spring Security, Spring OAuth2 Client) 및 회원 관련 모든 Backend 로직 및
Frontend 를 모두 담당하면서, 사실상 어떻게 주고 받을 것인가?? 에 대한 고민이 가장 컸습니다.
보안을 위해 HttpOnly Refresh Token 을 클라이언트 Cookie 에 담는다는 것이
클라이언트에서 "언제" HttpOnly Cookie 를 처리해야하고 "어떻게" Access Token 을 서버로부터 발급 받아야 할 것인가에 대해서
방법론에서 막막했습니다.
결론적으로 구글링을 통한 방법론은 찾지 못했습니다. 대부분 Rest Api 를 통해서 소셜로그인을 구현했고
Href 하이퍼링크를 통한, 백엔드 소셜로그인을 구현했다고 하더라도, 인증 토큰을 URI의 쿼리 스트링 부분에 포함시켜 전송하는 방식에 대해 설명할 뿐이었습니다.
이를 공부하고 학습한 후에, 프로젝트에 적용하는 것은 10일정도 걸렸지만, 아직도 거대하고 방대한 시큐리티 보안개념과 세세한 부분까지는 이해하지 못했습니다. 현 프로젝트에 적용한 저의 코드에 대해서 올바르게 이해하고, 설명할 수 있도록
지금과 같이 포스팅할 뿐입니다.
2. 일반 로그인과 소셜 로그인의 통합(Backend-Frontend)
두번째로 어려웠던 점은 로그인의 통합이었습니다. 아무래도 HttpOnly 방식의 쿠키를 선택하고, 그것을 클라이언트에서 처리하도록
React Hook 을 구현하다보니, 일반 로그인을 처리하는 방식 또한 같은 프로세스에서 처리하도록 하는 것이 좋아 보였습니다.
하지만 현재 JWTFilter 단에서 , 올바른 리프레쉬 토큰과 엑세스토큰을 클라이언트가 보냈을 때
컨텍스트 홀더에 인증정보를 담아 로그인을 처리하도록 구현했다보니 가장먼저 JWTFilter 를 리펙토링할 수 밖에 없었습니다.
그다음 LoginFilter 였습니다. 이 로그인 필터 또한, attemptAuthentication 에서 AuthenticationManager 에게 인증 토큰을 전달해주고, 성공과 실패에 대한 처리를 담당하고 있습니다.
단순히 Login Api 를 통해 로그인을 하는 것이 아니라
절차에 맞게 필터가 동작하기 때문에, 리펙토링을 면할 수 없었습니다.
결론적으로 , 시큐리티를 통해 처음으로 일반 로그인과 소셜로그인을 통합하다보니
전체적인 구현 프로세스와 동작 원리를 제대로 몰라
많은 리펙토링 과정을 거쳐서 코드가 작성되었습니다.
하지만 문제를 해결 하는 과정에서, 하나하나 답을 찾아가는 과정이 정말 재밌었습니다.