쇼핑몰 페이지는 기본적으로 로그인 여부와 상관 없이 이용 가능하지만, 특정 기능에는 로그인한 사용자의 정보가 필요하다.
문의 기능은 로그인 여부에 따라 정보 요청이 달라지고(비로그인시 이름,이메일을 입력), 문의 답변과 리뷰 작성은 로그인한 사용자만 가능하게 했다. 클라이언트에서 이를 가져오는 것은 보안에 취약하므로 내부에서 현재 로그인한 사용자 정보를 사용하는 것이 안전하다고 판단했다.
로그인 중인 사용자의 정보를 가져오는 방법에는 두 가지가 있다.
SecurityContextHolder 클래스를 이용하는 방법,
그리고 우리는 로그인에 jwt를 사용하고 있으므로, 클라이언트에서 요청을 보낼 때 Header에서 보내는 accesstoken을 사용할 수 있다.
Spring Security - 인증과 인가 : 정보 저장
이를 이용하려면 우선 Spring Security와 이의 인증/인가 과정에 대해 알아야 한다. 담당 팀원분이 구현해두신 코드가 있어 덕분에 이걸 보면서 쉽게 공부할 수 있었다. 무한한 감사...💕
우선 알아야 할 개념
- SecurityContextHolder : Spring Security에서 인증, 인가에 관련된 정보를 저장하고 관리할 수 있도록 제공하는 클래스
- 현재 실행 중인 스레드에 대한 SecurityContext를 제공
- SecurityContext : 인증된 사용자의 정보/권한 저장
- 기본적으로 인증 로직(로그인)에서 저장할 정보를 결정
- Spring Security의 인증(로그인) : username, password를 받아 UsernamePasswordAuthenticationToken을 생성해 인증
- UsernamePasswordAuthenticationToken : Spring Security에서 제공하는 인증 토큰
- 로그인 -> UsernamePasswordAuthenticationToken 객체에 인증 정보(username, password)를 담아 인증 진행
- 토큰 생성 -> 토큰에 인증 정보 + 권한 포함
- 토큰을 AuthenticationManager(Spring Security - 인증 수행 인터페이스)에 전달해 인증
- 인증 성공 -> 토큰이 Authentication 객체로 SecurityContextHolder에 저장
로그인(인증) 과정
우리 프로젝트에서 로그인 할 때 사용하는 LoginFilter
클래스의 메서드 역시 이 구조로 되어있는데, 우리는 로그인에 이메일을 사용하므로 username
에 email
을, password
에 password
를 담는다.
- 클라이언트에서 보낸
email
과password
검증 + 해당 사용자가 이메일 인증을 완료했는지 검증 UsernamePasswordAuthenticationToken
을 구현한authToken
에email
과password
를 담아 인증 진행- 로그인 메서드는
Authentication
을 구현했으므로 사용자의 인증 정보가 포함된authentication
를 반환
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
try{
// ... 이메일, 비밀번호, 이메일인증 true 등 기타 인증 로직
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password);
//token검증을 위한 AuthenticationManager로 전달
return this.getAuthenticationManager().authenticate(authToken);
} catch (IOException e) {
throw new AuthenticationServiceException("Error while attempting authentication.", e);
}
}
그리고 로그인이 성공하면 successfulAuthentication
메서드를 실행하는데, 로그인 메서드에서 반환한 authentication
을 파라미터로 받아 인증 정보를 사용하게 된다.
이를 통해 멤버 테이블에서 해당 사용자의 정보를 가져와 JWT accesstoken을 생성하고 memberId
, role
등을 저장해 로그인을 완료한다.
@Override
// 로그인 성공 시 실행하는 메소드 (여기서 JWT를 발급)
public void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
String email = authentication.getName();
Member memberByEmail = memberRepositoryV1.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("해당 이메일이 존재하지 않습니다."));
String memberId = memberByEmail.getId().toString();
// 권한을 문자열로 변환
String role = extractAuthority(authentication);
// 토큰 종류(카테고리), 유저이름, 역할 등을 페이로드에 담는다.
String newAccess = jwtUtil.createAccessToken("access", memberId, role);
String newRefresh = jwtUtil.createRefreshToken("refresh", memberId, role);
// [Refresh 토큰 - DB에서 관리합니다.] 리프레쉬 토큰 관리권한이 서버에 있습니다.
saveOrUpdateRefreshEntity(memberByEmail, newRefresh);
response.setCharacterEncoding("UTF-8");
// 로그인 성공시 -> [reponse Header] : Access Token 추가, [reponse Cookie] : Refresh Token 추가
setTokenResponse(response, newAccess, newRefresh);
//로그인 성공에 대한 추가 정보를 response body에 담음
response.getWriter().write("로그인에 성공했습니다.");
}
그리고 이렇게 생성한 accessToken을 헤더에 담아 반환함으로써 사용자는 accessToken으로 각 기능에 인가받을 수 있게 된다.
권한 확인 - 인가 과정
인가(권한 확인)하는 메서드는 JWTFilter
클래스의 메서드에서 진행되는데, 이 때 클라이언트에서 요청시 보낸 Header의 Authorization 에서 accessToken을 확인해 인가 작업을 한다.
@Slf4j
@RequiredArgsConstructor
public class JWTFilterV1 extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// request에서 Authorization 헤더 찾음
String authorization = request.getHeader("Authorization");
// ... Authorization 헤더 검증하고 accessToken 가져오는 로직 ...
// access 에 있는 username, role 을 통해 Authentication 사용자 정보를
Authentication authToken = getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
이 때 getAuthentication
메서드는 토큰의 유효성을 검사하고, UserDetails
(권한/인증 정보를 저장)를 상속한 CustomUserDetails
객체를 사용하여 UsernamePasswordAuthenticationToken
객체를 생성해 반환한다 -> 인증
그리고 인가가 완료되면 마지막에 getAuthentication
메서드에 토큰을 보내 인증된 사용자임을 확인하고, 드디어 SecurityContextHolder
에 정보를 저장한다.
이제 이 SecurityContextHolder를 사용하기만 하면 되는 것이다.
SecurityContextHolder 사용하기
이제 그냥 간단히 authentication
을 구현하면 SecurityContextHolder
를 사용할 수 있다.
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
System.out.println("authentication : " + authentication);
System.out.println("principal : " + authentication.getPrincipal());
이렇게 인증 정보를 가져와서 출력해보면,
authentication
에는 Pricipal, Credetial, Details 등 여러 요소가 담긴 것을 확인할 수 있는데, 여기서 Principal이 내가 사용해야 할 정보이다.
Principal은 Spring Security에서 현재 사용자를 나타내는 인터페이스인데, 주로 UserDetails 인터페이스를 구현한 객체이다. 위의 인증 과정에서 우리는 CustomUserDetails
를 사용했으므로, 이 정보가 담겨있는 것이다. authentication.getPrincipal()
을 출력해보면
CustomUserDetails
를 구현한 객체가 담겨있는 것을 확인할 수 있다.
accesstoken을 생성할 때 memberId와 role 을 담았으므로, CustomUserDetails에서는 이를 사용할 수 있다. 물론 memberId
를 가져올 getter메서드가 필요하다.
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final CustomMemberDto customMemberDto;
// ..
public Long getMemberId() {
return customMemberDto.getMemberId();
}
// ..
}
이제 CustomUserDetails
를 구현해 위에서 받아온 Pricipal
을 담아주고 memberId
를 가져오면
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
System.out.println("userDetail : " + userDetails);
System.out.println("MemberId : " + userDetails.getMemberId());
로그인 중인 사용자의 memberId
를 가져올 수 있게 된다.
JWT AccessToken 이용
API에 요청을 보낼 때 클라이언트에서 Header에 담아 보낸 AccessToken을 사용할 수도 있다.
위의 로그인 과정에서 AccessToken을 생성할 때 유저의 정보를 담아줬기 때문이다. JWTUtil
클래스에서 토큰에 저장된 정보를 가져올 수 있는 getter를 사용하면 된다.
// JWTUtil.java
@Component
@Slf4j
public class JWTUtil {
...
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) {
return parseToken(token).getExpiration().before(new Date());
}
....
}
Header의 Authorization을 가져온다.
String tokenStr = request.getHeader("Authorization");
System.out.println("token : " + tokenStr);
accessToken을 보낼 때는 Bearer + 토큰의 형태로 보내므로 7번째부터 잘라서 사용한다.
String accessToken = tokenStr.substring(7);
System.out.println("accessToken : " + accessToken);
System.out.println("member id : " + jwtUtil.getMemberId(accessToken));
System.out.println("role : " + jwtUtil.getRole(accessToken));
적용하기
나는 SecurityContextHolder를 사용하기로 했다.
로그인한 사용자 정보를 불러오는 메서드는 여러 메서드에서 사용해야 하므로
global.authorizaion
패키지를 만들어 MemberAuthorizationUtil
클래스로 분리했다.
public class MemberAuthorizationUtil {
private MemberAuthorizationUtil() {
throw new AssertionError();
}
public static Long getLoginMemberId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
return userDetails.getMemberId();
}
}
이제 이 클래스에서 getLoginMemberId
메서드를 가져와서 쓰면 된다. 문의 기능에 적용해 보자
InquiryReply
엔티티에는 Member
엔티티를 외래키로 참조해 답변자 객체를 저장하는 member
타입의 replyBy
필드가 있다. 여기에 현재 로그인중인 사용자 정보를 담는다.
기존에는 컨트롤러와 서비스 메서드에서 memberId
를 파라미터로 받았었다. 이젠 그게 필요 없으니까 파라미터에서 지워주고,
/**
* 문의 답변 등록
* @param replyRequest
* @param inquiryId
* @return
*/
@PostMapping("/new/{inquiryId}")
public ResponseEntity<String> createReply(@Valid @RequestBody InquiryReplyDto replyRequest, @PathVariable Long inquiryId) throws Exception {
Long createdId = replyService.createReply(replyRequest, inquiryId);
return ResponseEntity.status(HttpStatus.CREATED).body("답변 등록 완료 : "+createdId);
}
서비스 클래스의 문의 등록 메서드에 사용자 정보를 가져오는 것을 추가한다. 위에서 생성한 MemberAuthorizationUtil
클래스의 getLoginMemberId
메서드를 이용한다.
/**
* 문의 답변 등록
* @param replyRequest
* @param inquiryId
* @return
*/
@Transactional
public Long createReply(InquiryReplyDto replyRequest, Long inquiryId) throws Exception {
// 로그인 중인 유저의 memberId 찾기
Long memberId = MemberAuthorizationUtil.getLoginMemberId();
// 답변하는 Member 객체 = 로그인 중인 ADMIN 유저객체
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new NoSuchElementException(MEMBER_NOT_FOUND+" Id : " + memberId));
// 답변할 문의글
Inquiry inquiry = inquiryRepository.findById(inquiryId)
.orElseThrow(() -> new NoSuchElementException(WRITING_NOT_FOUND + " inquiryId: " + inquiryId));
// InquiryReply 저장
InquiryReply reply = new InquiryReply();
reply.setInquiry(inquiry);
reply.setReplyBy(member); // InquiryReply 의 ReplyBy 필드에 로그인중인 member 객체 담기
reply.setReplyTitle(inquiry.getInquiryTitle()); // 답변 제목은 문의 제목과 동일
reply.setReplyContent(replyRequest.getReplyContent());
inquiryReplyRepository.save(reply);
// Inquiry 테이블 의 isResponse -> true
inquiry.setIsResponse(true);
// 답변 메일 전송
sendReplyNotice(inquiry, reply);
return reply.getInquiryReplyId();
}
회원가입/로그인을 하고 문의 등록 API 를 실행시켜보면,
사용자 정보를 따로 넣어주지 않아도, 문의 테이블에 memberId가 잘 들어간 것을 확인할 수 있다.
GitHub 댓글