[ Spring Boot ] JWT - 로그인 구현

도즈·2025년 3월 16일

spring

목록 보기
11/12

📘 JWT 들어가기

  • 폼로그인을 막는다.
  • 세션을 사용하지 못하도록 한다.
  • API를 통해 로그인을 하면 JWT토큰을 Body와 쿠키를 통해 전송한다.
  • 이때 accessToken, refreshToken을 반환한다
  • Role은 사용하지 않을거라 제외했다 !

로그인 요청

  • POST /auth/register
{
  "email": "user@example.com",
  "password": "123456"
}

Response:

  • 성공 시: 200 OK
  • 실패 시: 400 Bad Request

로그인 응답

  • POST /auth/login
{
  "email": "user@example.com",
  "password": "123456"
}

Response:

  • 성공 시: 200 OK
    {
      "token": "JWT_ACCESS_TOKEN"
    }
  • 실패 시: 401 Unauthorized
    {
      "error": "Invalid credentials"
    }

로그아웃

POST /auth/logout

Headers:

Authorization: Bearer JWT_ACCESS_TOKEN

Response:

  • 성공 시: 200 OK Logged out successfully

📘 JWT 로그인구현

📌 예외처리 클래스 추가

먼저 들어가기 전 예외 처리를 위한 클래스 추가

import lombok.Getter;

@Getter
public enum JwtExceptionCode {
    UNKNOWN_ERROR("UNKNOWN_ERROR", "알 수 없는 오류"),
    NOT_FOUND_TOKEN("NOT_FOUND_TOKEN", "Headers에 토큰 형식의 값 찾을 수 없음"),
    INVALID_TOKEN("INVALID_TOKEN", "유효하지 않은 토큰"),
    EXPIRED_TOKEN("EXPIRED_TOKEN", "기간이 만료된 토큰"),
    UNSUPPORTED_TOKEN("UNSUPPORTED_TOKEN", "지원하지 않는 토큰");

    private final String code;  //값이 변하지 않도록 final
    private final String message;

    JwtExceptionCode(String code, String message) {
        this.code = code;
        this.message = message;
    }

    @Override
    public String toString() {   //예외 메시지를 출력할 때 더 유용한 정보 제공.
        return String.format("[%s] %s", code, message);
    }

}

1️⃣ Security Filter Chain 기본 정보 추가

천천히 추가할 예정이다

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/oauth/*","/").permitAll()
                        .anyRequest().authenticated())
                .csrf(csrf-> csrf.disable());
        
        return http.build();
    }
  
  • @EnableWebSecurity를 사용한다.
  • authorizeHttpRequests는 Spring Security의 설정 메서드 중 하나로, HTTP 요청에 대한 접근 제어를 설정하는 데 사용됩니다. 이 메서드는 특정 URL 패턴에 대해 사용자의 인증 및 권한을 요구하거나 제한할 수 있도록 도와준다
  • Spring Security에서 authorizeHttpRequests는 보통 http.security() 설정의 일부로 사용되며, 주로 어떤 URL이 특정 역할이나 권한을 가진 사용자에게만 허용될지 정의한다.

1-1 Cors 추가

  • 특정 포트번호를 허락해준다. (어디까지 허용할지 정해줄 수 있다)
    public CorsConfigurationSource configurationSource(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();

        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");

        source.registerCorsConfiguration("/**",config);
        return source;

    }
    
            //모두 허용한다고 해놨지만, 특정 포트만 접근할 수 있게 정할 수 있음
        config.addAllowedOrigin("*");

        //헤더 정보-> ContetType/json 뭐 이런거 Authrization 등 원하는 헤더만 들어올 수 있게 할 수 있다
        //*는 다 들어올 수 있다는 뜻 ㅇㅇ
        config.addAllowedHeader("*");

        //모든 메서드 허락
        config.addAllowedMethod("*");

        //특정 메서드 허락
        //get, post, delete 메서드만 허용한다 뭐 이런거 설정할 수 있음
        //아래 코드가 있을때 Put은 동작하지 않는다.
        config.setAllowedMethods(List.of("GET","POST","DELETE"));

        //url 설정을 넣어주는 역할, /admin으로 들어온 url은 특정 config를 넣어줄 수 있따
        //여러개의 config를 만들고, 원하는 url에 맞춰서 사용할 수 있음
        // /admin-> put만 가능하게 , /user -> post만 가능하게
        source.registerCorsConfiguration("/**",config);
  • config.addAllowedOrigin("*");
    • 모두 허용한다고 해놨지만, 특정 포트만 접근할 수 있게 정할 수 있음
  • config.addAllowedHeader("*");
    • 헤더 정보
      • ContetType/json 뭐 이런거 Authrization 등 원하는 헤더만 들어올 수 있게 할 수 있다
    • *는 다 들어올 수 있다는 뜻 ㅇㅇ
  • config.addAllowedMethod("*");
    • 모든 메서드 허락
  • config.setAllowedMethods(List.of("GET","POST","DELETE"));
    • 특정 메서드 허락
    • get, post, delete 메서드만 허용한다 뭐 이런거 설정할 수 있음
    • 위 코드는 Put은 동작하지 않는다.
  • source.registerCorsConfiguration("/**",config)
    • url 설정을 넣어주는 역할, /admin으로 들어온 url은 특정 config를 넣어줄 수 있따
    • 여러개의 config를 만들고, 원하는 url에 맞춰서 사용할 수 있음
    • admin-> put만 가능하게 , /user -> post만 가능하게

1-2 Password Encoder추가

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
  • 비밀번호는 그대로 저장하면 안되고, encoder를 통해서 DB에 저장해야 한다.

2️⃣ 토큰 생성 코드

    @PostMapping("/login")
    public ResponseEntity<UserLoginResponseDto> login(@RequestBody UserRequestDto userRequestDto){
        User user = userService.findByUserEmail(userRequestDto.getEmail());

        if(!userService.validPassword(userRequestDto.getPassword(),user.getPassword())){
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }

        String accessToken = jwtTokenizer.createAccessToken(user.getId(),user.getEmail());
        String refreshToken = jwtTokenizer.refreshAccessToken(user.getId(),user.getEmail());

        UserLoginResponseDto loginResponseDto=UserLoginResponseDto.builder()
                .accessToken(accessToken)
                .userId(user.getId())
                .email(user.getEmail())
                .build();

        return ResponseEntity.ok(loginResponseDto);

    }
    

유효한 비밀번호인지 확인을 해준다.

    public boolean validPassword(String dtoPassword, String userPassword){
        return passwordEncoder.matches(dtoPassword,userPassword);
    }
  • 만약 유효하지 않은 비밀번호라면 return
  • accessToken, refreshToken을 만들어둔 jwtTokenizer에서 발급받는다
  • 응답에 값을 채워준 후 return

jwtTokenizer는 토큰 생성과 검증을 해주는 클래스이다. 로그인을 구현한기전에 먼저 생성해야 한다 ! (아래 링크 참조)

JwtTokenizer 개념과 생성


3️⃣  토큰 인증

  • 토큰을 발급했지만, 아직 인증하는 코드는 만들지 않았다

3-1  JwtAuthenticationToken 클래스 생성

AbstractAuthenticationToken을 상속받아 인증 관련 정보를 담는 객체를 생성한다.

  • Spring Security에서 로그인 인증을 처리할 때 Authentication 객체를 사용하는데
  • JWT를 기반으로 인증을 할 것이기 때문에 별도의 JwtAuthenticationToken 클래스를 만든 것이다.
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
    private String token;
    private Object principal;
    private Object credentials;

    public JwtAuthenticationToken(Collection<? extends GrantedAuthority> authorities,
                                  Object principal, Object credentials) {
        super(authorities);
        this.principal =principal;
        this.credentials = credentials;
        this.setAuthenticated(true);
    }

    public JwtAuthenticationToken(String token){
        super(null);
        this.token = token;
        this.setAuthenticated(false);
    }

    @Override
    public Object getCredentials() {
        return this.credentials;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }
}
  • token: JWT 문자열을 저장할 변수
  • principal: 사용자 정보 (보통 UserDetails 객체)
  • credentials: 사용자 인증 정보 (보통은 비밀번호인데, JWT 기반 인증에서는 사용 X)
  • JwtAuthenticationToken 생성자
    • Collection<? extends GrantedAuthority> authorities
      • 사용자의 권한 정보(ROLE)
    • super(authorities)
      • 부모 클래스인 AbstractAuthenticationToken에게 권한 정보 넘김
    • this.setAuthenticated(true)
      • 토큰이 유효하다면 인증된 상태로 설정

3-2 JwtAuthenticationToken 사용방식

  1. JwtAuthenticationFilter에서 JWT를 파싱하여 사용자 정보를 추출한다.
  2. JwtAuthenticationToken 객체를 생성하여 Spring Security의 SecurityContext에 저장
  3. 이후 Spring Security가 인증된 사용자 정보를 관리하고 권한을 체크한다.

3-3 CustomerUserDetails 생성

public class CustomerUserDetails implements UserDetails {

    Long id;
    String email;
    String password;

    public CustomerUserDetails(Long id, String email, String password) {
        this.id = id;
        this.email = email;
        this.password = password;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 현재는 권한을 사용하지 않으므로 빈 리스트 반환
        return Collections.emptyList();
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return email;
    }
}
  • email을 id값으로 썼기 때문에 email값을 넣어줬다.
    • getUsername()고유한 식별자를 넣는 것이 중요하며, 이메일이 고유한 값이라면 이메일을 넣는 것이 합리적이다.

3-4  JwtAuthenticationFilter 생성

public class JwtAuthenticationFilterProject extends OncePerRequestFilter{
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        
    }
}
  • OncePerRequestFilterSpring Security에서 제공하는 추상 클래스이다.
    • OncePerRequestFilter는 요청에 대해 단 한번만 필터링을 하도록 보장
    • 여러 번 호출되지 않고 한 번만 실행되므로 성능에 유리하다.
  • doFilterInternal 메서드는 HTTP 요청을 처리하는 곳이다.

3-5  JwtAuthenticationFilter - getToken

request에서 token을 빼오기 위한 메서드

 public String getToken(HttpServletRequest request){
        //헤더에 있는지 확인
        String authorization = request.getHeader("Authorization");
        if(StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")){
            return authorization.substring(7);
        }

        //쿠키에 있는지 확인
        Cookie[] cookies = request.getCookies();
        if(cookies!=null){
            for(Cookie cookie : cookies){
                if("accessToken".equals(cookie.getName())){
                    return cookie.getValue();
                }
            }
        }

        //헤더에도 없고, 쿠키에도 없다면 ?
        return null;

    }
  • 헤더 또는 쿠키로 받을 수 있기 때문에, 두개 다 확인을 해준다.

3-6  JwtAuthenticationFilter - getAuthentication

Authentication 객체를 생성하고 이를 SecurityContextHolder에 저장하는 메서드

   private Authentication getAuthentication(String token){
        Claims claims = jwtTokenizer.parseAccessToken(token);
        String email = claims.getSubject();
        Long userId = claims.get("userId",Long.class);

        CustomerUserDetails customerUserDetails
                = new CustomerUserDetails(userId,email,"");
        //권한이 없기 때문에 빈 리스트 값을 넘겨준다.
        return new JwtAuthenticationToken(Collections.emptyList(),customerUserDetails,null);

    }
  • Spring Security는 사용자의 권한을 GrantedAuthority 인터페이스를 구현한 객체로 관리한다.
  • 객체는 주로 사용자가 가진 역할(Role)이나 권한(Authority)을 표현한다.
  • new CustomerUserDetails(userId,email,"");
    • jwt 인증 과정에서 비밀번호를 사용하지 않아서, 공백으로 로 설정한다.
    • JWT는 토큰 자체가 인증된 사용자임을 보장하므로, 비밀번호는 없이 사용하는 것이 일반적이다.
    • 가능하면 Custo
  • 하지만 현재 역할을 따로 추가하지 않았기 때문에, JwtAuthenticationToken에 빈 역할 리스트를 넘겨주었다.
    • Collections.emptyList()

3-7  JwtAuthenticationFilter → 만든 메서드 추가, 예외처리 추가

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

        String token = getToken(request);

        if(StringUtils.hasText(token)){
            try{
                
                Authentication authentication = getAuthentication(token);

                SecurityContextHolder.getContext().setAuthentication(authentication)
            
            }catch (ExpiredJwtException e){
                request.setAttribute("exception", JwtExceptionCode.EXPIRED_TOKEN.getCode());
                log.error("Expired Token : {}",token,e);
                SecurityContextHolder.clearContext();
                throw new BadCredentialsException("Expired token exception", e);
            }catch (UnsupportedJwtException e){
                request.setAttribute("exception", JwtExceptionCode.UNSUPPORTED_TOKEN.getCode());
                log.error("Unsupported Token: {}", token, e);
                SecurityContextHolder.clearContext();
                throw new BadCredentialsException("Unsupported token exception", e);
            } catch (MalformedJwtException e) {
                request.setAttribute("exception", JwtExceptionCode.INVALID_TOKEN.getCode());
                log.error("Invalid Token: {}", token, e);
                SecurityContextHolder.clearContext();
                throw new BadCredentialsException("Invalid token exception", e);
            } catch (IllegalArgumentException e) {
                request.setAttribute("exception", JwtExceptionCode.NOT_FOUND_TOKEN.getCode());
                log.error("Token not found: {}", token, e);

                SecurityContextHolder.clearContext();

                throw new BadCredentialsException("Token not found exception", e);
            }
        }
        filterChain.doFilter(request,response);
        }

1️⃣ getToken을 통해서 토큰을 얻어 온다.

2️⃣ getAuthentication을 통해서 Authentication을 생성한다.

  • JWT 토큰을 파싱하여 사용자 정보를 추출 (userId, email 등).
  • UserDetails(ex: CustomerUserDetails)를 생성하고,
  • 이를 기반으로 JwtAuthenticationToken 객체를 생성.

3️⃣ 생성한 Authentication을 security가 사용할 수 있도록 SecurityContextHolder에 추가한다.

Spring Security는 SecurityContextHolder에 저장된 Authentication을 통해 현재 사용자 정보를 확인한다.

  • doFilter()는 필터 체인에서 다음 필터로 요청과 응답을 전달하는 역할

✅ Config에 만든 Filter추가

  • 시큐리티 필터가 시작되기전에 토근을 확인하는 로그인 필터가 실행되어야 한다.
  • addFilterBefore사용
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/api/oauth/*","/","/oauth").permitAll()
                        .anyRequest().authenticated())
                //시큐리티가 시작하기 전에 사용될 수 있도록추가한다. 
                .addFilterBefore(new JwtAuthenticationFilterProject(jwtTokenizer), UsernamePasswordAuthenticationFilter.class)
                .csrf(csrf-> csrf.disable())
                .formLogin(form -> form.disable())
                .cors(cors -> cors.configurationSource(configurationSource()));

        return http.build();
    }
  • UsernamePasswordAuthenticationFilter는 기본적으로 폼 로그인(아이디+비밀번호) 인증을 담당한다.
  • JWT를 사용하는 경우, 폼 로그인을 사용하지 않기 때문에 JWT 필터를 먼저 실행하도록 설정.

JwtAuthenticationFilterProject가 Spring Security 인증 프로세스에서 가장 먼저 실행됨.


4️⃣ CustomerAuthenticationEntryPoint

시큐리티에서 인증되지 않은 사용자가, 인증해야 사용할 수 있는 보호된 리소스에 접근하려고 할때 동작하는 인터페이스

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {

        String exception  = (String) request.getAttribute("exception");

        if(isRestRequest(request)){
        
            //rest로 요청이 들어왔을때 수행 코드
            handleRestResponse(request,response,exception);

        }else{
            //page로 요청이 들어왔을때 수행 코드
            //handlePageResponse(request,response,exception);
        }

    }
  • 인증되지 않은 사용자가 접근할때는, 2가지 방식으로 접근이 될 것이다.
    • RESTful API로 요청이 들어왔을때
    • HTML 페이지이 들어왔을때
  • 현재는 api만 사용하고 있기 때문에 else 부분은 주석 처리 해뒀다

4-1 isRestRequest

지금 요청이 rest인지, page인지 확인하는 메서드

    private boolean isRestRequest(HttpServletRequest request) {
        String requestedWithHeader = request.getHeader("X-Requested-With");

        //page로 요청이 들어오면 false 반환됨 ㅇㅇ
        //false면 PAGE 요청	브라우저 직접 요청	HTML	@Controller
        return "XMLHttpRequest".equals(requestedWithHeader) //true면 ajax
                || request.getRequestURI().startsWith("/api/"); //true면 REST API
    }
  • ajax 만들때 생성되는 객체 XMLHttpRequest
    • X-Requested-With 헤더 값이 XMLHttpRequest면 보통 AJAX 요청으로 판단.
    • 또는 URL이 /api/로 시작하면 REST API 요청으로 간주.
  • AJAX 요청
    • JS에서 비동기 요청 JSON 주로 @RestController
  • REST API 요청
    • API 호출 (주로 /api/로 시작) JSON 주로 @RestController
  • 둘다 틀리면 페이지 false return

4-2 handleRestResponse

Restful로 요청이 들어왔을때 동작하는 메서드

    private void handleRestResponse(HttpServletRequest request, HttpServletResponse response, String exception) throws IOException {
        log.error("Rest Request - Commence Get Exception : {}", exception);

        if (exception != null) {
            if (exception.equals(JwtExceptionCode.INVALID_TOKEN.getCode())) {
                log.error("entry point >> invalid token");
                setResponse(response, JwtExceptionCode.INVALID_TOKEN);
            } else if (exception.equals(JwtExceptionCode.EXPIRED_TOKEN.getCode())) {
                log.error("entry point >> expired token");
                setResponse(response, JwtExceptionCode.EXPIRED_TOKEN);
            } else if (exception.equals(JwtExceptionCode.UNSUPPORTED_TOKEN.getCode())) {
                log.error("entry point >> unsupported token");
                setResponse(response, JwtExceptionCode.UNSUPPORTED_TOKEN);
            } else if (exception.equals(JwtExceptionCode.NOT_FOUND_TOKEN.getCode())) {
                log.error("entry point >> not found token");
                setResponse(response, JwtExceptionCode.NOT_FOUND_TOKEN);
            } else {
                setResponse(response, JwtExceptionCode.UNKNOWN_ERROR);
            }
        } else {
            setResponse(response, JwtExceptionCode.UNKNOWN_ERROR);
        }
    }

4-3 handlePageResponse

page로 요청이 들어왔을때 동작하는 메서드

    //페이지 요청 중에 예외가 발생했다면, 로그 남기고, 무조건 /loginform으로 리다이렉트
    private void handlePageResponse(HttpServletRequest request, HttpServletResponse response, String exception) throws IOException {
        log.error("Page Request - Commence Get Exception : {}", exception);

        if (exception != null) {
            // 추가적인 페이지 요청에 대한 예외 처리 로직을 여기에 추가할 수 있다. 
        }

        response.sendRedirect("/login-form");
    }

4-4 setResponse

예외 발생 시 응답 형식을 설정한다.

    private void setResponse(HttpServletResponse response, JwtExceptionCode exceptionCode) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //401

        HashMap<String, Object> errorInfo = new HashMap<>();
        errorInfo.put("message", exceptionCode.getMessage());
        errorInfo.put("code", exceptionCode.getCode());
        Gson gson = new Gson();
        String responseJson = gson.toJson(errorInfo);
        response.getWriter().print(responseJson);
    }

4-5 만든 authenticationEntryPoint를 추가해준다.

    private final JwtTokenizer jwtTokenizer;
    private final CustomerAuthenticationEntryPoint customerAuthenticationEntryPoint;
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/api/oauth/*","/","/oauth").permitAll()
                        .anyRequest().authenticated())
                .addFilterBefore(new JwtAuthenticationFilterProject(jwtTokenizer), UsernamePasswordAuthenticationFilter.class)
                .csrf(csrf-> csrf.disable())
                .formLogin(form -> form.disable())
                .cors(cors -> cors.configurationSource(configurationSource()))
                .exceptionHandling(excpetion -> excpetion
                        .authenticationEntryPoint(customerAuthenticationEntryPoint));;

        return http.build();
    }

4-6 로그인 컨트롤러 구현

    @PostMapping("/login")
    public ResponseEntity<UserLoginResponseDto> login(HttpServletResponse response,@RequestBody UserRequestDto userRequestDto){
        User user = userService.findByUserEmail(userRequestDto.getEmail());

        if(!userService.validPassword(userRequestDto.getPassword(),user.getPassword())){
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }

        String accessToken = jwtTokenizer.createAccessToken(user.getId(),user.getEmail());
        //String refreshToken = jwtTokenizer.refreshAccessToken(user.getId(),user.getEmail());

        //만약 토큰을 쿠키로 저장하고 싶다면 ?
        Cookie accessTokenCookie = new Cookie("accessToken",accessToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setPath("/");

        accessTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT/1000));

        response.addCookie(accessTokenCookie);

        UserLoginResponseDto loginResponseDto=UserLoginResponseDto.builder()
                .accessToken(accessToken)
                .userId(user.getId())
                .email(user.getEmail())
                .build();

        return ResponseEntity.ok(loginResponseDto);

    }
  • 데이버베이스에서 유저를 꺼내고, 들어온 요청의 비밀번호와 맞는지 확인한다.
    • 비밀번호가 틀리다면, 인증되지 않은 사용자 reutn
  • 비밀번호가 맞다면, access토큰을 추가 하고

시큐리티 자동 비밀번호 나오지 않게 만드는 방법

//기본적으로 사용자가 UserDetailsService 를 제공하지 않으면 시큐리티는 자동으로 생성..  그게 비밀번호 생성함. 
//우리는 CustomUserDetailsService 를 만들었음. 
// 첫번째 해결방법 
@Bean
    public UserDetailsService userDetailsService() {
        return new InMemoryUserDetailsManager(); // 빈 등록만 하고 사용자 추가 X
    }

//두번째 해결방법
   @Bean
    public UserDetailsService userDetailsService() {
        return username -> null; // 빈만 등록하고 로직은 사용하지 않음
    }

    @Bean
    public AuthenticationManager authenticationManager(UserDetailsService userDetailsService) {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService);
        authProvider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(authProvider);
    }

//세번째.. 

spring.security.user.name=
spring.security.user.password=

profile
도즈의 개발이야기

0개의 댓글