Backend 학습 📖

Spring boot 프로젝트에 Spring Security & JWT 도입하기 (MyBatis)

침착하고 가야할 곳에만 집중하는 달팽이 2024. 12. 13. 23:39

 Spring Boot 프로젝트를 만드는데 Spring Security 를 빠르게 도입해야 했다. 이럴 땐 유튜브의 인도 개발자분이 올려주신 영상을 보면 된다. 나는 Telusko 라는 개발자 분의 영상을 시청했다. 아래에 링크를 첨부하겠다. 단점은 한글 자막이 없다는 점이다.

https://www.youtube.com/watch?v=oeni_9g7too

 

 Telusko 님의 Spring Security 영상을 참고하여 빠르게 Spring Security 도입하는 방법에 대해 쓰겠다. 

Spring Security 도입하기 시작-!

개발자로서 우리는 보안을 위해 로그인/로그아웃에 신경을 쓴다! 로그인/로그아웃을 안전하게 하기 위해 Spring security를 쓰자!

1. 의존성 추가

(Spring boot 프로젝트를 처음 만들 때 Spring Security 선택하면 알아서 자동으로 추가해줌. )

먼저 pom.xml에 Spring Security 관련 의존성을 추가합니다:

하는 김에 JWT를 위한 의존성도 3가지 추가해주기

<dependencies>
    <!-- Spring Security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <!-- JWT 지원을 위한 의존성 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

 이걸 추가하는 순간 페이지를 들어갈려면 로그인 폼이 자동으로 뜸.

다소 못생긴 로그인 창
당장 css 손 보고 싶게 생긴 로그인 창

초기 아이디는 user, 비번은 콘솔 창을 보면 이렇게 알려줌.

이 비밀 번호는 개발 용도로만 사용된다.

만약 이 상태에서 로그아웃 하고 싶으면 (그냥 테스트 용도로) locathost[포트번호]/logout 이라고 치면 로그아웃 폼도 나옴.

로그인을 한 번 하면 로그아웃하지 않는 이상 다시 로그인을 하라고 요청하지 않는다. 이게 가능한 이유는? → session id 때문이다. session id 는 로그인할 때마다 생성된다.

이게 저장돼서 로그아웃을 하거나 시크릿 창을 띄워서 session id에 있는 id 값을 접근하지 못하게 될 때만 다시 로그인을 요청한다.

session id 확인하는 방법: 개발자 도구 f12 로 열기 -> Application -> Cookies에서 확인 가능함. 

여기서 session 앞에 붙은 J 는 Java 웹 어플리케이션이라 붙은거다.

출력해서 화면에 보는 방법도 있다.

HttpServletRequest 클래스의 getSession()메서드와 getId() 메서드를 활용하면 session id 를 화면에 출력해볼 수도 있다. 

	@GetMapping("/")
	public String getMethodName(HttpServletRequest request) {
		return "당신의 세션 아이디는: " + request.getSession().getId();
	}

새로고침해도 안 바뀌는 걸 쉽게 확인 가능!

1- 2 자동으로 나오는 로그인 폼 안 쓰도록 하기

여기서 filter 라는 애를 건드릴거임.

Spring Security 는 원래 설정된 여러 보안 필터가 있음. 이 필터는 요청이 dispatcher servlet 으로 가기 전에 걸러주는 역할을 함. 우리는 우리 마음대로 사이트를 만들기 위해 이 필터들을 커스터마이징할거임.

이 필터 중 하나가 UsernamePasswordAuthenticationFilter 라는 애고 얘가 바로 계속 로그인 폼을 띄워주는 애임. (이름 외울 필요 x)

2. 보안 설정 클래스 생성

security 패키지를 만들고 SecurityConfig 클래스를 생성합니다:

@Configuration  // 스프링 설정 파일임을 선언
@EnableWebSecurity // 기본 스프링 시큐리티 대신 커스텀 설정을 사용하겠다는 선언
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter; // JWT 필터 주입

    // 생성자 주입
    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // CSRF 보안 비활성화 (REST API에서는 보통 disable)
            // - REST API는 stateless하기 때문에 CSRF 공격으로부터 안전함
            .csrf(csrf -> csrf.disable())  

            // 세션 사용 안함 (JWT 인증 방식을 사용하기 때문)
            // - 클라이언트가 요청할 때마다 JWT를 헤더에 담아보내므로 세션 불필요
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
           
            // 각 경로별 인증 설정
            .authorizeHttpRequests(auth -> auth
                // 로그인, 회원가입 등 인증 관련 경로는 모두 허용
                .requestMatchers("/api/auth/**").permitAll()    
                // swagger, 공개 API 등 인증 없이 접근 가능한 경로
                .requestMatchers("/api/public/**").permitAll()  
                // 그 외 모든 경로는 인증 필요
                .anyRequest().authenticated()                   
            )
           
            // JWT 필터 추가 
            // - UsernamePasswordAuthenticationFilter 전에 실행되어야 함
            // - 클라이언트가 보낸 JWT 토큰의 유효성을 검증하는 커스텀 필터
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCrypt: 단방향 해시 함수, 같은 비밀번호도 매번 다른 해시값 생성
        // - 레인보우 테이블 공격 방지를 위한 솔트(salt) 자동 생성
        return new BCryptPasswordEncoder();
    }
   
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration authenticationConfiguration) throws Exception {
        // Spring Security의 인증을 담당하는 매니저 생성
        // - 이후 로그인 시도시 사용됨
        // - UserDetailsService와 PasswordEncoder를 통해 인증 처리
        return authenticationConfiguration.getAuthenticationManager();
    }
}

 여기서 csrf 토큰이란?

 

웹 보안을 위한 CSRF 토큰, API에서는 필요 없다.

CSRF 토큰은 Cross-Site Request Forgery 공격을 막기 위한 보안 장치다. 서버가 발급한 고유한 토큰을 요청마다 함께 보내서, "이 요청이 우리 웹사이트에서 정상적으로 시작된 거다"라고 증명하는 방식이다.

하지만 최근 API 개발에서는 CSRF 토큰이 필요 없다. 이유는?

현대 API는 보통 Authorization 헤더에 JWT같은 토큰을 넣어서 인증을 처리하는데, 브라우저가 이 헤더를 자동으로 전송하지 않는다. 덕분에 CSRF 공격 자체가 불가능하다.

그래서 많은 현대 웹 애플리케이션에서는 CSRF 보호를 비활성화한다. API 인증만으로 충분한 보안이 가능하기 때문이다.

 

3. 사용자 엔티티 및 Repository 구현

User 엔티티

@Getter
@Setter
public class User {
    private Long id;
    private String username;
    private String password;
    private String email;
    private String role;
    private boolean enabled;
}

UserMapper 인터페이스 (MyBatis)

JPA 대신 MyBatis 를 사용해서 mapper 와 mapper.xml 을 만든다. 

@Mapper
public interface UserMapper {
    User findByUsername(String username);
    void save(User user);
    void update(User user);
}

User Mapper XML

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.demo.mapper.UserMapper">
    <!-- 아이디로 사용자 찾기 -->
    <select id="findByUsername" resultType="com.example.demo.domain.User">
        SELECT id, username, password, email, role, enabled 
        FROM users 
        WHERE username = #{username}
    </select>

    <!-- 새 사용자 저장하기 -->
    <insert id="save" parameterType="com.example.demo.domain.User">
        INSERT INTO users (username, password, email, role, enabled) 
        VALUES (#{username}, #{password}, #{email}, #{role}, #{enabled})
    </insert>

    <!-- 사용자 정보 업데이트 -->
    <update id="update" parameterType="com.example.demo.domain.User">
        UPDATE users 
        SET password = #{password}, 
            email = #{email}, 
            role = #{role}, 
            enabled = #{enabled} 
        WHERE id = #{id}
    </update>
</mapper>

4. UserDetails 서비스 구현

Spring Security가 우리가 만든 User를 알아볼 수 있게 해주는 서비스 클래스를 만듦. 

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserMapper userMapper;
    private final PasswordEncoder passwordEncoder;  // 비밀번호 암호화에 사용

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // DB에서 사용자 정보를 가져온다
        User user = userMapper.findByUsername(username);
        
        if (user == null) {
            throw new UsernameNotFoundException("없는 사용자입니다: " + username);
        }

        // Spring Security가 알아들을 수 있는 UserDetails 객체로 변환
        Collection<GrantedAuthority> authorities = List.of(
            new SimpleGrantedAuthority(user.getRole())
        );

        return new org.springframework.security.core.userdetails.User(
            user.getUsername(),
            user.getPassword(),
            user.isEnabled(),
            true,                 // 계정 만료 여부
            true,                 // 자격 증명 만료 여부
            true,                 // 계정 잠금 여부
            authorities          // 권한 목록
        );
    }
}

5. JWT 관련 클래스 구현

JwtTokenProvider

JwtTokenProvider는 JWT 토큰을 만들고 검증하는 핵심 클래스!

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    
    @Value("${jwt.secret}")
    private String jwtSecret;

    @Value("${jwt.expiration}")
    private int jwtExpiration;

    // JWT 토큰 생성
    public String generateToken(Authentication authentication) {
        String username = authentication.getName();
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + jwtExpiration);

        // JWT 빌더로 토큰 생성
        return Jwts.builder()
            .setSubject(username)
            .setIssuedAt(now)         // 토큰 발행 시간
            .setExpiration(expiryDate) // 토큰 만료 시간
            .signWith(getSignInKey(), SignatureAlgorithm.HS256)
            .compact();
    }

    // 토큰에서 사용자 이름 추출
    public String getUsernameFromToken(String token) {
        return Jwts.parserBuilder()
            .setSigningKey(getSignInKey())
            .build()
            .parseClaimsJws(token)
            .getBody()
            .getSubject();
    }

    // 토큰 유효성 검사
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                .setSigningKey(getSignInKey())
                .build()
                .parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    // 서명 키 생성
    private Key getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

 여기서 중요한 부분 몇 가지를 짚어보자면: - jwtSecret: application.properties에서 설정한 비밀키를 가져와서 씀. 이 키로 토큰을 암호화하고 복호화 하는거임 - jwtExpiration: 토큰 유효기간! 보통 24시간으로 설정하는데, 서비스 성격에 따라 조절하면 됨. 

 

 generateToken() 메서드는 로그인 성공했을 때 호출되는데, 여기서 실제 JWT 토큰이 만들어짐. 빌더 패턴으로 이것저것 넣어주는데: - setSubject(): 사용자 이름 넣기 - setIssuedAt(): 토큰 발행 시간 (까먹지 말자!) - setExpiration(): 토큰 만료 시간 - signWith(): 아까 그 시크릿 키로 암호화

JwtAuthenticationFilter

모든 요청마다 실행되면서 JWT 토큰이 유효한지 검사하는 필터임

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final CustomUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
    ) throws ServletException, IOException {
        try {
            String jwt = getJwtFromRequest(request);

            if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
                String username = jwtTokenProvider.getUsernameFromToken(jwt);
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                
                UsernamePasswordAuthenticationToken authentication = 
                    new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                    );
                
                authentication.setDetails(
                    new WebAuthenticationDetailsSource().buildDetails(request)
                );

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception ex) {
            logger.error("JWT 인증 처리 중 오류 발생", ex);
        }

        filterChain.doFilter(request, response);
    }

    // Authorization 헤더에서 JWT 토큰 추출
    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

 doFilterInternal() 메서드가 실제로 일하는 부분인데:

1. Authorization 헤더에서 JWT 토큰을 꺼냄 ("Bearer " 떼고!)

2. 토큰이 유효한지 검사

3. 유효하면 토큰에서 사용자 정보 꺼내서

4. SecurityContext에 인증 정보를 넣어줌 (이래야 Spring Security가 인증된 사용자라고 인식함)

 여기서 실수하기 쉬운게 SecurityContextHolder.getContext().setAuthentication(authentication) 이 부분인데, 이거 까먹으면 토큰은 있는데 인증이 안 된것처럼 동작하니까 꼭 넣어주자!

6. 인증 컨트롤러 구현

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider tokenProvider;
    private final PasswordEncoder passwordEncoder;
    private final UserMapper userMapper;

    @PostMapping("/login")
    public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                loginRequest.getUsername(),
                loginRequest.getPassword()
            )
        );

        SecurityContextHolder.getContext().setAuthentication(authentication);
        String jwt = tokenProvider.generateToken(authentication);

        return ResponseEntity.ok(new JwtResponse(jwt));
    }

    @PostMapping("/signup")
    public ResponseEntity<?> registerUser(@Valid @RequestBody SignupRequest signUpRequest) {
        if (userMapper.findByUsername(signUpRequest.getUsername()) != null) {
            return ResponseEntity.badRequest().body("Username is already taken!");
        }

        User user = new User();
        user.setUsername(signUpRequest.getUsername());
        user.setPassword(passwordEncoder.encode(signUpRequest.getPassword()));
        user.setEmail(signUpRequest.getEmail());
        user.setRole("ROLE_USER");
        user.setEnabled(true);

        userMapper.save(user);

        return ResponseEntity.ok("User registered successfully!");
    }
}

 인증 컨트롤러는 실제로 로그인/회원가입 API를 처리하는 부분. login API에서는:

1. 받은 아이디/비번으로 인증 시도

2. 성공하면 JWT 토큰 생성해서 반환

3. 실패하면 알아서 예외 터짐 (Spring Security가 처리해줌)

 

 signup API는 좀 단순한데:

1. 중복 아이디 체크 (이거 빼먹으면 큰일남!)

2. 비밀번호 암호화 (절대 평문으로 저장하면 안 됨!!!)

3. 기본 권한 설정하고 저장

7. 프로퍼티 설정

application.properties 또는 application.yml에 JWT 관련 설정을 추가:

jwt.secret=your-jwt-secret-key
jwt.expiration=86400000  # 24시간

 jwt.secret은 길고 복잡하게 만들어야 안전함. Base64로 인코딩된 값을 쓰는게 좋은데, 당연히 git에 올릴 때는 빼고 올려야 함. expiration은 보통 24시간(86400000ms)으로 설정하는데, 너무 길면 보안에 취약하고 너무 짧으면 사용자가 짜증날 수 있으니 적절히 조절하자.

 

 

참고한 인도 선생님 영상: