IT/Live Coding

Spring Boot + JWT로 인증 시스템 구현 (테스트 영상 & 소스 코드 포함)

어흥꼬비 2025. 3. 22.

springBoot와 JWT를 이용한 인증 서버를 간단히 구축해봤다.

메타코딩님의 "스프링부트 시큐리티 - JWT"를 보고, 이를 바탕으로 스스로 복기하며 학습한 내용을 기록으로 남긴다.


동영상 🎬

JwtAuthorizationFilter가 JwtAuthenticationFilter보다 먼저 실행되게 해야 한다.
동영상만 보면 JwtAuthorizationFilter보다 JwtAuthenticationFilter이 먼저 실행되어야 한다고 오해할듯 하다.
이유는 JwtAuthorizationFilter에서 존재하는 사용자고 유효한 토큰인지 검증 후 검증 실패 시 다음 필터로 넘어가게 하면 안되기 때문이다. (즉, 로그인까지 못가게 해야 함)

소스 💻

application.yml

server:
  port: 9090
  servlet:
    context-path: /
    encoding:
      charset: UTF-8
      enabled: true
      force: true

spring:
  devtools:
    livereload.enabled: true
    restart.enabled: true
  datasource:
    driver-class-name: net.sf.log4jdbc.sql.jdbcapi.DriverSpy
    url: jdbc:log4jdbc:mysql://localhost:3309/db명
    hikari:
      username: 사용자
      password: 비밀번호
      connectionTimeout: 10000
      maximum-pool-size: 15
      max-lifetime: 600000
      readOnly: false
      connection-test-query: SELECT 1

  jpa:
    hibernate:
      ddl-auto: update #create update none
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
    show-sql: true

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.10'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.lsy'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'com.auth0:java-jwt:3.10.2'
    // Logging
    implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

 

java 소스

PrincipalDetails

package com.lsy.jwtvideo.auth;

import com.lsy.jwtvideo.model.User;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

@RequiredArgsConstructor
@Data
// 시큐리티의 컨텍스트에 담을 수 있는 객체인 USerDetails를 구현한 구현체 클래스(이 클래스에 User객체를 담아서 반환)
public class PrincipalDetails implements UserDetails {

    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 롤을 GrantedAuthority 타입 리스트로 반환
        return user.getRoleList().stream()
                .map(role -> (GrantedAuthority) () -> role)
                .toList();
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }
}

PrincipalDetailsService

package com.lsy.jwtvideo.auth;

import com.lsy.jwtvideo.model.User;
import com.lsy.jwtvideo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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
@Slf4j
public class PrincipalDetailsService implements UserDetailsService {

    private final UserRepository repository;

    @Override
    // 시큐리티에서 로그인 호출 시 인증하는 메서드
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // username을 db에서 조회 후 값이 있으면 PrincipalDetails에 담아서 반환, 없으면 예외 발생
        User findUser = repository.findByUsername(username);

        if(findUser == null) {
            log.error("User not found username: {}", username);
            throw new UsernameNotFoundException("User not found with username: {} " + username);
        }

        log.info("User found with username : {}", username);
        return new PrincipalDetails(findUser);
    }
}

CorsConfig

package com.lsy.jwtvideo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        // CORS 설정을 위한 CorsConfiguration 객체 생성
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        // 자바스크립트에서 서버 응답을 처리할 수 있도록 자격 증명 허용 설정 (쿠키나 인증 정보 포함)
        config.setAllowCredentials(true);
        // 모든 출처(Origin)에서 오는 요청을 허용
        config.addAllowedOrigin("*");
        // 모든 헤더에 대해 요청을 허용
        config.addAllowedHeader("*");
        // 모든 HTTP 메서드(GET, POST, PUT 등)에 대해 요청을 허용
        config.addAllowedMethod("*");
        // "/api/**" 경로로 시작하는 모든 요청에 대해 CORS 정책을 적용
        source.registerCorsConfiguration("/api/**", config);
        // CORS 필터를 반환하여 요청에 대해 설정된 CORS 정책을 적용
        return new CorsFilter(source);
    }
}

SecurityConfig

package com.lsy.jwtvideo.config;

import com.lsy.jwtvideo.filter.JwtAuthenticationFilter;
import com.lsy.jwtvideo.filter.JwtAuthorizationFilter;
import com.lsy.jwtvideo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
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.EnableMethodSecurity;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.filter.CorsFilter;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig {

    private final CorsFilter corsFilter;
    private final AuthenticationConfiguration authenticationConfiguration;
    private final UserRepository repository;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                // 세션 사용하지 않겠다.
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .addFilter(corsFilter)
                // form login 사용하지 않겠다.
                .formLogin(form -> form.disable())
                // http 기본 인증 방식 사용하지 않겠다.
                .httpBasic(basic -> basic.disable())
                // JWT 필터(토큰 생성)
                .addFilter(new JwtAuthenticationFilter(authenticationManager()))
                // JWT 토큰 인증
                .addFilterBefore(new JwtAuthorizationFilter(repository), JwtAuthenticationFilter.class)
                .authorizeHttpRequests(auth ->
                        auth.requestMatchers("/api/v1/user/**")
                                .hasAnyRole("USER", "MANAGER", "ADMIN")
                            .requestMatchers("/api/v1/manager/**")
                                .hasAnyRole("MANAGER", "ADMIN")
                            .requestMatchers("/api/v1/admin/**")
                                .hasAnyRole("ADMIN")
                                .anyRequest().permitAll()
                );
        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
        return authenticationConfiguration.getAuthenticationManager(); 
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

ApiController

package com.lsy.jwtvideo.controller;


import com.lsy.jwtvideo.model.User;
import com.lsy.jwtvideo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class ApiController {

    //테스트니까 컨트롤러에서 바로 리포 주입
    private final UserRepository repository;
    private final PasswordEncoder passwordEncoder;

    @PostMapping("join")
    public String join(@RequestBody User user) {
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        user.setRoles(user.getRoles());
        repository.save(user);
        return "회원가입 완료!";
    }

    // API 테스트용 메서드
    @GetMapping("/api/v1/user")
    public String user() {
        return "user~~~ 토큰 검증 성공";
    }

    @GetMapping("/api/v1/manager")
    public String manager() {
        return "manager";
    }

    @GetMapping("/api/v1/admin")
    public String admin() {
        return "admin";
    }
}

JwtAuthenticationFilter

package com.lsy.jwtvideo.filter;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.lsy.jwtvideo.auth.PrincipalDetails;
import com.lsy.jwtvideo.model.User;
import com.lsy.jwtvideo.utils.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
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;

@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter  extends UsernamePasswordAuthenticationFilter {

    // JWT인증 받으려면 이 객체 사용해야 함
    private final AuthenticationManager authenticationManager;

    // /login 호출 시 실행되는 메서드

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {

//        log.info("로그인 요청 왔음~~~~~");
        // 요청 파라미터를 읽어서 토큰 객체 생성
        try {
            ObjectMapper om = new ObjectMapper();
            User user = om.readValue(request.getInputStream(), User.class);
            log.info("user :: {}", user);

            // 토큰 객체 생성
            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());

            Authentication authenticated = authenticationManager.authenticate(authenticationToken);
            log.info("authenticated :: {}", authenticated);
            //authenticationManager로 토큰 객체를 인증(이 때 사용자가 db에 존재하는지도 체크)
            // authenticated값이 있으면 return
            return authenticated;
        } catch(IOException ie) {
            ie.printStackTrace();
        }
        return null;
    }

    // 로그인 성공 시 이 메서드 실행됨
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {
        
        
//        log.info("찍히나?인증되었을거야");
//        authResult > 인증된 사용자 정보 담겨 있음
        // Principal을 꺼내는데 반환탕비이 Object라서 다운캐스팅을 해서 받는다.
        PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
        // 토큰 생성
        //이 함수까지 왔다는 것은 로그인이 되었다는 의미다(올바른 사용자란 의미지)
        // JwtTokenUtil을 사용하여 JWT 토큰 생성
        String jwtToken = JwtUtil.createToken(principalDetails);
        // 응답 헤더에 토큰 추가
        response.addHeader("Authorization", "Bearer " + jwtToken);
    }
}

JwtAuthorizationFilter

package com.lsy.jwtvideo.filter;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.lsy.jwtvideo.auth.PrincipalDetails;
import com.lsy.jwtvideo.jwt.JwtProperties;
import com.lsy.jwtvideo.model.User;
import com.lsy.jwtvideo.repository.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

// 토큰 검증
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {

    private final UserRepository repository;

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

        log.info("JWT 토큰 인증 요청~~~");
        // 헤더를 읽는다.
        String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);
        log.info("jwtHeader :: {}", jwtHeader);

        // 로그인, 회원가입 화면은 그냥 필터를 타게 한다.
        String requestURI = request.getRequestURI();
        if (requestURI.equals("/login") || requestURI.equals("/join")) {  // 로그인, 회원가입 경로 예시
            filterChain.doFilter(request, response);  // 로그인 요청은 JWT 검증을 건너뜁니다.
            return;
        }
        // 유효하지 않은 토큰
        if(jwtHeader == null || !jwtHeader.startsWith(JwtProperties.TOKEN_PREFIX)) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 토큰입니다.");
            return;
        }

        try {
            // 토큰만 읽어온다. "Bearer " 이거 우측의 값들
            String jwtToken = request.getHeader(JwtProperties.HEADER_STRING).replace("Bearer ", "");

            log.info("jwtToken :: {}", jwtToken);

            // 토큰 검증
            String username = JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build()
                    .verify(jwtToken)
                    .getClaim("username")
                    .asString();

            // 토큰 검증 후 username이 반환되면 정상으로 토큰이 검증된 경우다.(아닐 경우 예외발생됨)
            if(username != null) {
                log.info("정상 서명됨~");
                // 토큰은 유효하니까 이제 해당 사용자가 db 테이블에 존재하는지를 체크 한다.
                User findUser = repository.findByUsername(username);
                PrincipalDetails principalDetails = new PrincipalDetails(findUser);

                log.info("principalDetails :: {}", principalDetails.getUsername());
                // 토큰객체를 만들어서 시큐리티 컨텍스트에 저장 후 다음 필터로 넘김
                Authentication authentication = new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities());
                log.info("authentication :: {}", authentication);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }

            filterChain.doFilter(request, response);

        } catch (TokenExpiredException e) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "토큰이 만료되었습니다.");
        } catch (JWTVerificationException e) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 토큰입니다.");
        } catch (Exception e) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "토큰 검증 중 오류가 발생했습니다.");
        }
    }
}

JwtUtil

package com.lsy.jwtvideo.utils;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.lsy.jwtvideo.auth.PrincipalDetails;
import com.lsy.jwtvideo.jwt.JwtProperties;
import lombok.extern.slf4j.Slf4j;

import java.util.Date;

@Slf4j
public class JwtUtil {

    public static String createToken(PrincipalDetails principalDetails) {
        //만료시간
        Date expiration = new Date(System.currentTimeMillis() + 1000 * 60);
        log.info("Token expiration: {}", expiration);

        //JWT 생성
        return JWT.create()
                .withSubject(principalDetails.getUsername())
                //만료시간
                .withExpiresAt(expiration)
                //아이디(번호)
                .withClaim("id", principalDetails.getUser().getId()) // 사용자 ID
                //사용자이름(id)
                .withClaim("username", principalDetails.getUser().getUsername()) // 사용자 이름
                //암호화할 개인키
                .sign(Algorithm.HMAC512(JwtProperties.SECRET)); // 암호화 키
    }
}

JwtProperties

package com.lsy.jwtvideo.jwt;

public class JwtProperties {
    public static final String SECRET = "비밀키";
    public static final String TOKEN_PREFIX = "Bearer ";
    public static final String HEADER_STRING = "Authorization";
}

개인 스터디 기록을 메모하는 공간이라 틀린점이 있을 수 있습니다.

틀린 점 있을 경우 댓글 부탁드립니다.


Reference: 🎥 https://www.youtube.com/watch?v=cv6syIv-8eo&list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah&index=13&ab_channel=%EB%A9%94%ED%83%80%EC%BD%94%EB%94%A9

댓글