카테고리 없음

Spring Security - #3.로그인 (세션 → JWT) / 비즈니스 로직과 인증,인가의 분리

luminous_dev 2025. 2. 7. 03:10

Security Filter 순서

 

login.html 수정

<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link rel="preconnect" href="https://fonts.gstatic.com">
    <link rel="stylesheet" type="text/css" href="/css/style.css">
    <script src="https://code.jquery.com/jquery-3.7.0.min.js"
            integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/js-cookie@3.0.5/dist/js.cookie.min.js"></script>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<div id="login-form">
    <div id="login-title">Log into Select Shop</div>
    <br>
    <br>
    <button id="login-id-btn" onclick="location.href='/api/user/signup'">
        회원 가입하기
    </button>
    <div>
        <div class="login-id-label">아이디</div>
        <input type="text" name="username" id="username" class="login-input-box">

        <div class="login-id-label">비밀번호</div>
        <input type="password" name="password" id="password" class="login-input-box">

        <button id="login-id-submit" onclick="onLogin()">로그인</button>
    </div>
    <div id="login-failed" style="display: none" class="alert alert-danger" role="alert">로그인에 실패하였습니다.</div>
</div>
</body>
<script>
    $(document).ready(function () {
        // 토큰 삭제
        Cookies.remove('Authorization', {path: '/'});
    });

    const host = 'http://' + window.location.host;

    const href = location.href;
    const queryString = href.substring(href.indexOf("?")+1)
    if (queryString === 'error') {
        const errorDiv = document.getElementById('login-failed');
        errorDiv.style.display = 'block';
    }

    function onLogin() {
        let username = $('#username').val();
        let password = $('#password').val();

        $.ajax({
            type: "POST",
            url: `/api/user/login`,
            contentType: "application/json",
            data: JSON.stringify({username: username, password: password}),
        })
            .done(function (res, status, xhr) {
                window.location.href = host;
            })
            .fail(function (xhr, textStatus, errorThrown) {
                console.log('statusCode: ' + xhr.status);
                window.location.href = host + '/api/user/login-page?error'
            });
    }
</script>
</html>

 

JWT를 적용한 Filter 만들기 

JwtAuthenticationFilter

 

이전에는 로그인 처리를 하고 JWT 생성해서 반환하는 것을 컨트롤러/ 서비스/Repository 단에서 했었음

이전 UserController

//로그인
@PostMapping("/user/login")
public String login(LoginRequestDto requestDto, HttpServletResponse res) {
    try {
        userService.login(requestDto,res);
    } catch (Exception e) {
        return "redirect:/api/user/login-page?error";
    }

    return "redirect:/";
}

 

근데 이제 Filter을 배웠으니 인증/인가와 비즈니스 로직 처리를 분리해야 함

 

이후 UserController

: UserController에서 로그인 구현하지 않음

컨트롤러 / 서비스에서 로그인 관련된 것 삭제하기


AuthenticationFilter(인증) 

JwtAuthenticationFilter

메서드 별 설명 

더보기

첫번째 메서드 

//로그인 시도하는 메서드
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    log.info("로그인 시도");
    try {
        LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
                                                                        //request.getInputStream()  = Request 요청 Body 부분에 username과 password가 넘어올 것임 JSON 형식으로 
                                                                        //.getInputStream()  = 데이터 가져오기
                                                                        //두번째 파라메터 : 변환할 Object 타입

        //검증, 인증 처리하는 메서드 .authenticate() -토큰을 넣어줘야 함
        return getAuthenticationManager().authenticate( //상속 받아서 사용할 수 있음
                //토큰 만들기
                new UsernamePasswordAuthenticationToken(
                        requestDto.getUsername(), //첫번째 값
                        requestDto.getPassword(), //두번째 값
                        null //권한 쪽은 null
                )
        );
    } catch (IOException e) {
        log.error(e.getMessage());
        throw new RuntimeException(e.getMessage());
    }
}
LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);

 

ObjectMapper().readValue(변환할 파라메터 ,변환할 타입); : Json 형태의 String Value를 Object로 만드는 것

 

//검증, 인증 처리하는 메서드 .authenticate() -토큰을 넣어줘야 함
return getAuthenticationManager().authenticate( //상속 받아서 사용할 수 있음
        //토큰 만들기
        new UsernamePasswordAuthenticationToken(
                requestDto.getUsername(), //첫번째 값
                requestDto.getPassword(), //두번째 값
                null //권한 쪽은 null
        )
);

 

여기서 UsernamePasswordAuthenticationToken

 

두번째 메소드

//로그인을 성공하고 수행되는 두번째 메소드
@Override
//Authentication authResult : Authentication 객체를 파라메터 받아옴 -> 이 안에는 UserDetails가 들어있음 -> 그 안에 UserDetailsImpl가 들어있음
//UserDetails와 UserDetailsService를 Authentication Manager이 사용
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
    log.info("로그인 성공 및 JWT 생성");
    String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername(); //(UserDetailsImpl) authResult.getPrincipal() = @AuthenticationPrinciple
    UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole(); //토큰 생성시 두번째 파라메터에 넣어주기 위함



    //토큰 만들기
    String token = jwtUtil.createToken(username, role);

    //Cookie 생성하고 Response 객체에 넣어주는 메서드 실행
    jwtUtil.addJwtToCookie(token, response);

}

 

.getPrincipal() = @AuthenticationPrinciple

 

왜 Authentication을 UserDetailsImpl로 형변환해줘야 하는가?
UserDetailsImpl이 UserDetails 인터페이스를 구현한 클래스이기 때문 -> 실제 사용자 정보를 제공하는 역할
형변환 하지 않으면 Autehtication은 기본적인 UserDetails 인터페이스로만 접근할 수 있어, 구체적인 사용자 정보를 확인할 수 없음 

 

전체 코드

package com.sparta.springauth.jwt;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sparta.springauth.dto.LoginRequestDto;
import com.sparta.springauth.entity.UserRoleEnum;
import com.sparta.springauth.security.UserDetailsImpl;
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.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import java.io.IOException;

@Slf4j(topic = "로그인 및 JWT 생성")
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    //UsernamePasswordAuthenticationFilter에 있는 기능 사용 위해 상속
    //직접하는 이유 : Session이 아니라 JWT를 해야해서
    
   //로그인 성공하면 JWT생성까지 할 것임 이에 필요한 jwtUtil 주입
    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
        setFilterProcessesUrl("/api/user/login"); //UsernamePasswordAuthenticationFilter의 메소드
                            //우리가 지정한 post방식의 로그인 url (custom 위해 WebSecurityconfig에서 해줬던 설정 여기에)
    }

    //로그인 시도하는 메서드
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        log.info("로그인 시도");
        try {
            LoginRequestDto requestDto = new ObjectMapper().readValue(request.getInputStream(), LoginRequestDto.class);
                                                                            //request.getInputStream()  = Request 요청 Body 부분에 username과 password가 넘어올 것임 JSON 형식으로 
                                                                            //.getInputStream()  = 데이터 가져오기
                                                                            //두번째 파라메터 : 변환할 Object 타입

            //검증, 인증 처리하는 메서드 .authenticate() -토큰을 넣어줘야 함
            return getAuthenticationManager().authenticate( //상속 받아서 사용할 수 있음
                    //토큰 만들기
                    new UsernamePasswordAuthenticationToken(
                            requestDto.getUsername(), //첫번째 값
                            requestDto.getPassword(), //두번째 값
                            null //권한 쪽은 null
                    )
            );
        } catch (IOException e) {
            log.error(e.getMessage());
            throw new RuntimeException(e.getMessage());
        }
    }

    //로그인을 성공하고 수행되는 두번째 메소드
    @Override
    //Authentication authResult : Authentication 객체를 파라메터 받아옴 -> 이 안에는 UserDetails가 들어있음 -> 그 안에 UserDetailsImpl가 들어있음
    //UserDetails와 UserDetailsService를 Authentication Manager이 사용
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        log.info("로그인 성공 및 JWT 생성");
        String username = ((UserDetailsImpl) authResult.getPrincipal()).getUsername(); //(UserDetailsImpl) authResult.getPrincipal() = @AuthenticationPrinciple
        UserRoleEnum role = ((UserDetailsImpl) authResult.getPrincipal()).getUser().getRole(); //토큰 생성시 두번째 파라메터에 넣어주기 위함



        //토큰 만들기
        String token = jwtUtil.createToken(username, role);

        //Cookie 생성하고 Response 객체에 넣어주는 메서드 실행
        jwtUtil.addJwtToCookie(token, response);

    }

    //로그인을 실패했을 때
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        log.info("로그인 실패");
        response.setStatus(401);
    }
}

 

** DistpatcherServlet

(분배의 역할 - 들어오는 HTTP 요청을 **핸들러(Controller)**로 전달하고, 그 응답을 다시 클라이언트에게 전달하는 역할)

 


AuthorizationFilter (인가)

JwtAuthorizationFilter

들어온 Jwt를 검증 + 인가(허가) 해줄 것임 

Jwt 검증 위해 jwtUtil 및  그 사용자가 있는지 없는지 확인 위해 UserDetailsService  (.loadUserByUsername()을 쓸 것 )

 

package com.sparta.springauth.jwt;

import com.sparta.springauth.security.UserDetailsServiceImpl;
import io.jsonwebtoken.Claims;
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.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Slf4j(topic = "JWT 검증 및 인가")
public class JwtAuthorizationFilter extends OncePerRequestFilter { // OncePerRequestFilter 상속 받으면 HttpServletRequest,HttpServletResponse 받아옴

    private final JwtUtil jwtUtil;
    private final UserDetailsServiceImpl userDetailsService;

    public JwtAuthorizationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {

        String tokenValue = jwtUtil.getTokenFromRequest(req); //.getTokenFromRequest(req): cookie에서 JWT 가진 쿠키 가져오는 메소드

        //순수 토큰 만들기
        if (StringUtils.hasText(tokenValue)) {
            // JWT 토큰 substring
            tokenValue = jwtUtil.substringToken(tokenValue);
            log.info(tokenValue);

            if (!jwtUtil.validateToken(tokenValue)) {
                log.error("Token Error");
                return;
            }

            Claims info = jwtUtil.getUserInfoFromToken(tokenValue);

            try {
                setAuthentication(info.getSubject()); //info.getSubject() = user 이름 가지고 올 수 있음 - 토큰 만들 때 이름을 subject에 넣어서
            } catch (Exception e) {
                log.error(e.getMessage());
                return;
            }
        }

        filterChain.doFilter(req, res);
    }

    // 인증 처리
    public void setAuthentication(String username) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        Authentication authentication = createAuthentication(username); //Authentication 구현체 받음
        context.setAuthentication(authentication);//Authentication 구현체 받은 것을 Context에 넣을 것임

        SecurityContextHolder.setContext(context);//Context를 다시 ContextHolder에 넣음 => 인가된 사용자야 (인증처리 완료)
    }

    //위 인증 처리의 createAuthentication(username);에서 넘어옴
    // 인증 객체 생성
    //userDetail이 필요함 그래서 userDetailsService.loadUserByUsername(username)를 해줌
    private Authentication createAuthentication(String username) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        //UserDetails를 사용해서 principle,credentials, 권한 넣는 부분 채움
        //authentication 만들어져서 인증 처리로 돌아감
    }
}

 

 

Filter을 만들면 Filter을 등록하고 추가적인 값들도 줘야 함

WebSecurityConfig에서

package com.sparta.springauth.config;

import com.sparta.springauth.jwt.JwtAuthorizationFilter;
import com.sparta.springauth.jwt.JwtAuthenticationFilter;
import com.sparta.springauth.jwt.JwtUtil;
import com.sparta.springauth.security.UserDetailsServiceImpl;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity // Spring Security 지원을 가능하게 함
public class WebSecurityConfig {

    private final JwtUtil jwtUtil; //filter에 넣어야해서 주입
    private final UserDetailsServiceImpl userDetailsService; //filter에 넣어야해서 주입
    private final AuthenticationConfiguration authenticationConfiguration;

    public WebSecurityConfig(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService, AuthenticationConfiguration authenticationConfiguration) {
        this.jwtUtil = jwtUtil;
        this.userDetailsService = userDetailsService;
        this.authenticationConfiguration = authenticationConfiguration;
    }

    //AuthenticationManager 만들기 - Bean으로 등록
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    //인증 Filter 등록 + Bean으로 등록하는 메서드
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() throws Exception {
        //인증 Filter 생성
        JwtAuthenticationFilter filter = new JwtAuthenticationFilter(jwtUtil);

        //JwtAuthenticationFilter에서 getAuthenticationManager()로 가져오므로 set도 해줘야 함
        filter.setAuthenticationManager(authenticationManager(authenticationConfiguration));
        //AuthenticationManager만드는 메서드에 먼저 넣고 그 값을 set해주기
        return filter;
    }

    //AuthorizationFilter
    @Bean
    public JwtAuthorizationFilter jwtAuthorizationFilter() {
        return new JwtAuthorizationFilter(jwtUtil, userDetailsService);
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        // CSRF 설정
        http.csrf((csrf) -> csrf.disable());

        // 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
        http.sessionManagement((sessionManagement) ->
                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );

        http.authorizeHttpRequests((authorizeHttpRequests) ->
                authorizeHttpRequests
                        .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정
                        .requestMatchers("/api/user/**").permitAll() // '/api/user/'로 시작하는 요청 모두 접근 허가
                        .anyRequest().authenticated() // 그 외 모든 요청 인증처리
        );

        http.formLogin((formLogin) ->
                formLogin
                        .loginPage("/api/user/login-page").permitAll()
        );


        //여기까지해도 Filter만 만들어준 거임 =  SecurityFilterChain에 넣어주지 않았기 때문에 끝난게 아님
        //-> SecurityFilter안에 끼워넣어줘야 함 (어떤 순서에 언제 끼워넣을건지 말해주지 않음)
        //아래 두개의 코드가 관련된 내용임

        // 필터 관리
        http.addFilterBefore(jwtAuthorizationFilter(), JwtAuthenticationFilter.class);
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); //UsernamePasswordAuthenticationFilter : username과 password 확인해서 인증처리
        //.addFilterBefore 해당 filter 전에 내가 넣을 거다 (UsernamePasswordAuthenticationFilter 전에 jwtAuthenticationFilter를 먼저 수행하겠습니다)
        //로그인을 하기 전에 인가하고 인가가 제대로 되지 않았으면 로그인하는 게 올바른 순서라서 jwtAuthenticationFilter가 먼저
        return http.build();
    }
}

 

세션 대신 JWT 방식 사용하기 위한 설정 

  // 기본 설정인 Session 방식은 사용하지 않고 JWT 방식을 사용하기 위한 설정
        http.sessionManagement((sessionManagement) ->
                sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        );

SessionCreationPolicy 정책을 Stateless로 할 거임 -> JSON 형식으로 들어가던 id값 안 뜰거임 

 

 

HomeController

@AuthenticationPrinciple을 통해 실제 접속한 사용자의 이름을 보낼 수 있음 

package com.sparta.springauth.controller;

import com.sparta.springauth.security.UserDetailsImpl;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {
//메인 페이지에 가기 위해 만듦
    @GetMapping("/")
    public String home(Model model, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        model.addAttribute("username", userDetails.getUsername());
        return "index";
    }
}