[BONBON] 로그인 파트 구현 - SpringBoot + JWT + Vue.js(1)

나무나무·2025년 8월 7일

프로젝트 BonBon

목록 보기
4/10

🔒 백엔드 로그인 - JWT 구현

최초 로그인 프로세스

  • 필자의 지독한 글씨체와 그림 실력으로 조금이나마 jwt 토큰 흐름을 이해하기 위해 그려보았다.
  • 최초 로그인 시 UserDetailService에서 사용자 정보 존재 여부를 확인 → 확인될 경우 userDetail 객체를 반환 받은 뒤 → JwtTokenProvider에서 AccessTokenRefreshToken을 생성 → Client에게 반환하는 흐름이다.

로그인 이후 요청 처리 프로세스

  • 로그인 된 상태에서 HTTP 요청을 서버에 보낼 때의 흐름을 마찬가지로 필자의 지독한 필체와 그림으로 표현했다.
  • 로그인된 상태에서 HTTP 요청을 보낼 땐 LocalStorage에 담긴 사용자의 Token 정보를 Authorization Header에 담아 보낸다.
  • ServerToken을 받으면 JwtAuthenticationFilter에서 doFilterInternalJwtTokenProviderToken 추출을 거친다. → 해당 Token이 유효한지 검사 → 유효할 경우 getAuthentication를 통해 Authentication 객체를 생성해 SecurityContextHolder에 저장한다.
  • SecurityContextHolder에 인증 객체가 저장된 상태이기 때문에 Principal 등을 이용해 controller에서 사용자 정보를 쉽게 조회할 수 있게 된다.


Jwt 로그인 구현

JwtToken

@Getter
@ToString
@Data
@AllArgsConstructor
public class JwtToken {

    // 클라이언트에 보낼 토큰
    private String accessToken;     // accessToken
    private String refreshToken;    // refreshToken
}

AuthServiceImpl

@Service
@Transactional
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {

    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    // 로그인 로직
    @Override
    @Transactional
    public JwtToken signIn(UserLoginDto userLoginDto) {

        // email, password 기반 Authentication 객체 생성
        UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(userLoginDto.getEmail(), userLoginDto.getPassword());

        // 검증 진행 -> authenticationManagerBuilder 이용
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(auth);

        String accessToken = jwtTokenProvider.createToken(userLoginDto.getEmail(), authentication.getAuthorities(), jwtTokenProvider.getAccessTokenExpirationTime());
        String refreshToken = jwtTokenProvider.createRefreshToken(userLoginDto.getEmail());

        // 위의 과정을 통과할 경우 -> AccessToken + RefreshToken 발급
        return new JwtToken(accessToken, refreshToken);
    }

    // 로그아웃 로직
    @Override
    public void logout(String token){
        // token에서 access Token만 추출
        String accessToken = jwtTokenProvider.resolveToken(token);

        // 추출한 토큰 유효성 확인
        if(!jwtTokenProvider.validateToken(accessToken)){
            throw new UserException(ExceptionMessage.INVALID_ACCESS_TOKEN);
        };

        // access Token을 BlackList에 담고
        jwtTokenProvider.addBlackList(accessToken);

        // RefreshToken은 삭제하면 됨.
        jwtTokenProvider.deleteRefreshToken(accessToken);
    }


    // 토큰 재발급 로직
    // 일반적으로 만료 되기 5분 정도 전에 물어보는 방식으로 가야할 것 같음
    @Override
    public JwtToken refresh(String token) {

        // refresh 토큰 추출
        String refreshToken = jwtTokenProvider.resolveToken(token);

        // 해당 토큰이 유효한지 확인 -> RefreshToken 이 이미 만료된 경우 재발급 불가
        if(refreshToken == null || !jwtTokenProvider.validateToken(refreshToken)){
            throw new UserException(ExceptionMessage.INVALID_REFRESH_TOKEN);
        };

        // 추출한 토큰에서 사용자 추출
        User user = userRepository.findByEmail(jwtTokenProvider.getUserName(refreshToken))
                .orElseThrow(() -> new UserException(ExceptionMessage.USER_NOT_FOUND));

        Collection<GrantedAuthority> authorities = user.getUserType() != null ?
                    List.of(new SimpleGrantedAuthority(user.getUserType().name())) :
                    List.of();

        // Refresh는 이전에 쓰던거 그대로 유지
        return new JwtToken(
                jwtTokenProvider.createToken(user.getEmail(), authorities, jwtTokenProvider.getAccessTokenExpirationTime()),
                refreshToken
        );
    }
}

UserDetailService

@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailService implements UserDetailsService {

    private final UserRepository userRepository;

    // 사용자 존재 여부 확인
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<User> user = userRepository.findByEmail(username);
        if(user.isPresent()) {
            return new PrincipalDetails(user.get());
        }

        // 사용자 없으면 예외 발생
        throw new UsernameNotFoundException("해당 이메일을 갖는 사용자가 존재하지 않습니다: " + username);
    }
}

JwtAuthenticationFilter

  • Filter의 경우 어떤 요청은 통과시키고 어떤 요청은 Filter를 거치게 만들어야 해서 생각보다 꼬이는 일이 많았다.
  • 코드가 약.. 간 지저분한 느낌이 조금 있다.
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final AuthService authService;

    private static final List<String> EXCLUDED_PATHS = List.of(
            "/swagger-ui/**", "/v3/api-docs/**", "/bonbon/user/login", "/bonbon/email/send", "/bonbon/email/verify",
            "/bonbon/user/email-check", "/bonbon/user/headquarters", "/bonbon/user/franchisee/without-owner",
            "/bonbon/user/region", "/health", "/actuator/health", "/files/upload"
    );
    private static final AntPathMatcher pathMatcher = new AntPathMatcher();

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // Swagger UI 경로는 인증 필터를 통과시킴
        if (isExcludedRequest(request)) {
            filterChain.doFilter(request, response);
            return;
        }

        // 받은 RequestAuthorization 헤더에서 TokenParsing 해서 추출
        String token = jwtTokenProvider.resolveToken(request.getHeader("Authorization"));

        if (isValidToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            // 얻은 authentication 객체를 SecurityContextHolder에 넣어줌
            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(request, response);
        } else {
            handleInvalidToken(response);
        }
    }

    private boolean isExcludedRequest(HttpServletRequest request) {
        String path = request.getRequestURI();
        String method = request.getMethod();

        return EXCLUDED_PATHS.stream().anyMatch(pattern -> pathMatcher.match(pattern, path))
                || ("POST".equalsIgnoreCase(method) && (
                "/bonbon/user/franchisee".equals(path) || "/bonbon/user/manager".equals(path)
        ));
    }

    // token 유효 여부 확인
    private boolean isValidToken(String token) {
        return token != null &&
                jwtTokenProvider.validateToken(token) &&
                jwtTokenProvider.hasRoleClaim(token) &&
                !jwtTokenProvider.isBlackListed(token);
    }

    // 토큰이 유효하지 않은 경우
    private void handleInvalidToken(HttpServletResponse response) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write("JWT Token is invalid or blacklisted");
    }
}

AuthController

@Slf4j
@RestController
@RequestMapping("/bonbon/user")
@RequiredArgsConstructor
@Tag(name = "Auth", description = "로그인/로그아웃")
public class AuthController {

    private final AuthService authService;

    // JWT 로그인
    @PostMapping("/login")
    @Operation(summary = "로그인", description = "이메일, 비밀번호로 로그인한다.")
    public ResponseEntity<JwtToken> signIn(
            @Valid @RequestBody UserLoginDto userLoginDto){

        JwtToken jwtToken = authService.signIn(userLoginDto);

        return ResponseEntity.ok(jwtToken);
    }

    @PostMapping("/logout")
    @Operation(summary = "로그 아웃", description = "AccessToken 정보를 바탕으로 로그아웃 한다.")
    public ResponseEntity<Void> logOut(
            @RequestHeader("Authorization") String token
    ){
        authService.logout(token);
        return ResponseEntity.noContent().build();
    }

    @PostMapping("/refresh")
    @Operation(summary = "토큰 재발급", description = "AccessToken을 Refresh Token 정보를 바탕으로 재발급한다.")
    public ResponseEntity<JwtToken> refresh(
            @RequestHeader("Authorization") String token
    ){
        JwtToken refreshToken = authService.refresh(token);
        return ResponseEntity.ok(refreshToken);
    }
}

AuthServiceImpl

@Slf4j
@RestController
@RequestMapping("/bonbon/user")
@RequiredArgsConstructor
@Tag(name = "Auth", description = "로그인/로그아웃")
public class AuthController {

    private final AuthService authService;

    // JWT 로그인
    @PostMapping("/login")
    @Operation(summary = "로그인", description = "이메일, 비밀번호로 로그인한다.")
    public ResponseEntity<JwtToken> signIn(
            @Valid @RequestBody UserLoginDto userLoginDto){

        JwtToken jwtToken = authService.signIn(userLoginDto);

        return ResponseEntity.ok(jwtToken);
    }

    @PostMapping("/logout")
    @Operation(summary = "로그 아웃", description = "AccessToken 정보를 바탕으로 로그아웃 한다.")
    public ResponseEntity<Void> logOut(
            @RequestHeader("Authorization") String token
    ){
        authService.logout(token);
        return ResponseEntity.noContent().build();
    }

    @PostMapping("/refresh")
    @Operation(summary = "토큰 재발급", description = "AccessToken을 Refresh Token 정보를 바탕으로 재발급한다.")
    public ResponseEntity<JwtToken> refresh(
            @RequestHeader("Authorization") String token
    ){
        JwtToken refreshToken = authService.refresh(token);
        return ResponseEntity.ok(refreshToken);
    }
}

profile
백엔드 개발자 나무입니다

0개의 댓글