[ STUDY ]/Spring Boot

[ JWT를 활용한 Spring Security ] 파일 설명

김강니 2024. 9. 13. 03:16

사실 Security를 이해하고 코드를 짠게 아니라서 다시 정리하면서 리팩토링해보려구 한다.....

우선 각 파일에 대해서 먼저 정리함!

 

LoginController

사용자가 이메일과 비밀번호를 입력하여 인증을 시도하면, 이를 검증한 후 JWT 토큰을 발급하는 API

@PostMapping("/login")
public ResponseEntity<?> getToken(@RequestBody AccountCredentialsDTO accountCredentials) {
    try {
        // UsernamePasswordAuthenticationToken을 사용해 이메일과 비밀번호로 인증 객체 생성
        UsernamePasswordAuthenticationToken creds = new UsernamePasswordAuthenticationToken(
                accountCredentials.getEmail(),
                accountCredentials.getPassword());

        // 인증 시도 후, 성공 시 Authentication 객체 반환
        Authentication auth = authenticationManager.authenticate(creds); 

        // 인증 성공 시 SecurityContextHolder에 인증 정보 저장
        SecurityContextHolder.getContext().setAuthentication(auth);

        // JwtService를 통해 사용자 정보를 바탕으로 JWT 토큰 발급
        String jwts = jwtService.getToken(auth.getName());

        // JWT 토큰을 AUTHORIZATION 헤더에 포함하여 클라이언트에 전달
        return ResponseEntity.ok()
                .header(HttpHeaders.AUTHORIZATION, jwts)
                .header(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, "Authorization")
                .body(Map.of("message", "Auth Success"));
    } catch (BadCredentialsException e) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("message", "Auth Fail", "error", "Invalid email or password"));
    }
}

 

authenticationManager.authenticate(creds)
사용자가 입력한 이메일과 비밀번호를 사용하여 인증을 시도하고, 성공 시 Authentication 객체를 반환
jwtService.getToken(auth.getName())
인증에 성공하면 사용자의 이름을 기반으로 JWT 토큰을 생성

 

 

 

PasswordConfig

비밀번호 암호화를 위한 설정 파일로, 비밀번호를 안전하게 암호화하고 인증 과정에서 암호화된 비밀번호를 비교하는 역할

@Configuration
public class PasswordConfig {
    // BCryptPasswordEncoder를 사용하여 비밀번호를 암호화
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
PasswordEncoder()
BCryptPasswordEncoder를 빈으로 등록하여 비밀번호를 안전하게 암호화하고 인증 시 비밀번호를 비교

 

 

 

UserDetailServiceImpl

사용자의 이메일을 기반으로 DB에서 사용자를 조회하고, 해당 사용자의 비밀번호와 권한 정보를 반환해 스프링 시큐리티에서 인증을 처리

@Log4j2
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    //AuthenticationManager의 인증과정에서 호출되어짐
    //이곳에서는 테이블의 사용자가 있는지를 확인하고, 사용자 id, password, role을 넣어줘야함
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {

        Optional<User> user = this.userRepository.findByEmail(email);

        UserBuilder builder = null;
        //User테이블에 email이 존재
        if(user.isPresent()) {
            // 사용자 정보가 존재하면 UserDetails 객체 생성 (비밀번호와 권한 포함)
            User currentUser = user.get();
            builder = org.springframework.security.core.userdetails.User.withUsername(currentUser.getEmail());
            builder.password(currentUser.getPassword());
            builder.roles(String.valueOf(currentUser.getUserType()));
        }else{
            throw new UsernameNotFoundException("User not found : " + email);
        }
        return builder.build();
    }
}

 

userRepository.findByEmail(email)
사용자 이메일을 기반으로 DB에서 사용자 조회
builder.password(currentUser.getPassword());
인증에 사용할 비밀번호
builder.roles(String.valueOf(currentUser.getUserType()));
사용자의 권한(roles)을 설정하여 인증에 사용

 

 

 

SecurityConfig

스프링 시큐리티의 설정을 커스터마이징하여 JWT 필터를 추가하고, 특정 엔드포인트에 대한 인증 정책을 설정

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final UserDetailsService userDetailsService;
    private final JwtRequestFilter jwtRequestFilter;
    private final PasswordEncoder passwordEncoder;

    // 사용자 인증을 위한 userDetailsService 설정 및 비밀번호 암호화 방식 설정
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        // 사용자 정보와 암호화된 비밀번호로 인증을 처리
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder);
    }

    // AuthenticationManager를 빈으로 생성하여 인증 관리
    @Bean
    @Override
    public AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 세션을 사용하지 않는 REST 서버이므로 csrf 비활성화 및 세션 정책을 STATELESS로 설정
        http.cors().and()
                .csrf().disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션을 유지하지 않음
                .and()
                .authorizeRequests()
                .antMatchers("/api/personal/**").hasRole("PERSONAL") // PERSONAL 권한이 있는 사용자만 접근 가능
                .antMatchers("/api/company/**").hasRole("COMPANY") // COMPANY 권한이 있는 사용자만 접근 가능
                .antMatchers("/**").permitAll(); // 나머지 요청은 인증 없이 접근 가능

        // JWT 필터를 추가하여 API 요청마다 토큰을 검증
        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }

    // CORS 설정을 추가하여 프론트엔드의 요청을 허용
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.addAllowedOrigin("http://localhost:3000"); // 허용할 출처 설정
        configuration.addAllowedMethod("*"); // 모든 HTTP 메소드 허용
        configuration.addAllowedHeader("*"); // 모든 헤더 허용
        configuration.setAllowCredentials(true); // 자격 증명 허용

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 CORS 설정 적용
        return source;
    }
}

 

configureGlobal(AuthenticationManagerBuilder auth)
userDetailsService와 passwordEncoder를 설정하여 사용자 인증과 비밀번호 암호화를 처리
authenticationManager
인증 관리 객체를 빈으로 생성하여 여러 서비스에서 사용 가능하게 함
configure(HttpSecurity http)
REST API 서버로 세션을 사용하지 않으므로 세션 관리를 STATELESS로 설정하고, 특정 엔드포인트에 대해 권한을 제한
CorsConfigurationSource
CORS 설정을 통해 외부 출처에서의 요청을 허용하고, 모든 메소드와 헤더에 대해 허용된 설정을 추가

 

 

 

JwtRequestFilter

스프링 시큐리티의 설정을 커스터마이징하여 JWT 필터를 추가하고, 특정 엔드포인트에 대한 인증 정책을 설정

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
    String authorizationHeader = request.getHeader("Authorization");

    if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
        String jwt = authorizationHeader.substring(7);
        String username = jwtService.extractUsername(jwt);

        // JWT 토큰이 유효한 경우, 인증 정보 설정
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

            if (jwtService.validateToken(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
    }

    filterChain.doFilter(request, response);
}

 

authorizationHeader = request.getHeader("Authorization");
요청 헤더에서 JWT토큰을 추출
jwtServuce.validateToken(jwt, userDetails)
JWT 토큰의 유효성을 검사하고, 유효하면 인증 정보를 설정하여 이후 요청에서 사용자를 인증
filterChain.doFilter(request, response);
다음 필터나 실제 요청 처리 로직으로 넘어감

 

 

 

JwtService

JWT 토큰을 생성하고, 발급된 토큰이 유효한지 검증하는 로직을 처리하는 서비스

@Component
@RequiredArgsConstructor
@Service
public class JwtService {

    private final UserRepository userRepository;

    // 토큰의 유효기간
    static final long EXPIRE_TIME = 1000 * 60 * 60 * 24; // 1일
    static final String PREFIX = "Bearer "; // 토큰을 식별하기 위한 접두사
    static final Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256); // 비밀키

    // 비밀키로 서명된 JWT토큰 발급
    public String getToken(String email) {
        Optional<User> user = userRepository.findByEmail(email);
        if(user.isEmpty()) {
            throw new IllegalArgumentException("User not found");
        }
        String role = user.get().getUserType().toString();
        Long userId = user.get().getUserId();
        String token = Jwts.builder()
                .setSubject(email)
                .claim("role", role)
                .claim("userId", userId)
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRE_TIME))
                .signWith(key)
                .compact();

        return token;
    }

    public String getAuthUser(HttpServletRequest request) {
        String token = request.getHeader(HttpHeaders.AUTHORIZATION);

        // 토큰이 헤더에 존재한다면
        if (token != null && token.startsWith(PREFIX)) {
            // token을 비밀키로 풀었을 때 user가 잘 추출되면
            return Jwts.parserBuilder()
                    .setSigningKey(key)
                    .build()
                    .parseClaimsJws(token.replace(PREFIX, ""))
                    .getBody()
                    .getSubject();
        }
        return null;
    }

    // 토큰에서 모든 클레임 추출
    public Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token.replace(PREFIX, ""))
                .getBody();
    }

    // 특정 클레임 추출
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    // 토큰에서 사용자 이름 추출
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // 토큰에서 역할 추출
    public String extractRole(String token) {
        return extractClaim(token, claims -> claims.get("role", String.class));
    }

    // 토큰에서 사용자 ID 추출
    public Long extractUserId(String token) {
        return extractClaim(token, claims -> claims.get("userId", Long.class));
    }

    // 토큰 만료 여부 확인
    public Boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

    // 토큰 유효성 검증
    public Boolean validateToken(String token, String username) {
        final String extractedUsername = extractUsername(token);
        return (extractedUsername.equals(username) && !isTokenExpired(token));
    }
}

 

getToken(String email)
• 사용자의 이메일을 기반으로 DB에서 사용자를 조회합니다.
• 조회된 사용자의 이메일, 역할, 사용자 ID를 이용해 JWT 토큰을 생성합니다.
• 이 토큰은 서명된 후 반환되며, 클라이언트에게 전달됩니다.
• 토큰에는 유효기간, role, userId 정보가 포함됩니다.
getAuthUser()
• 클라이언트의 요청 헤더에서 Authorization 헤더를 가져와 JWT 토큰을 확인합니다.
• 토큰이 Bearer로 시작하면, 해당 토큰을 해석하여 토큰에서 **사용자 이메일 (subject)**를 추출하고 반환합니다.
• 이 함수는 비로그인 상태에서 인증된 사용자 정보를 확인하는 데 사용됩니다.

extractAllClaims():

토큰의 모든 클레임(Claims)을 추출
(사용자 ID, 역할, 만료 시간 등)

extractUsername()

extractRole()
extractUserId()
토큰에서 각각의 정보를 추출
isTokenExpired()
• 토큰의 만료 시간을 확인하여, 토큰이 만료되었는지 여부를 반환
• 만료된 토큰은 더 이상 유효하지 않으며, 재인증 필요

validateToken()

• JWT 토큰의 유효성을 검증합니다.
• 토큰에서 추출한 사용자 이름이 현재 요청의 사용자 이름과 일치하고, 토큰이 만료되지 않았을 때만 유효하다고 판단함

 

인증 및 토큰 발급 과정 요약
1. 사용자가 이메일과 비밀번호로 로그인 시도
2. UserDetailsServiceImpl에서 사용자의 비밀번호와 권한 정보를 데이터베이스에서 조회
3. 인증 성공 시 JwtService에서 JWT 토큰을 발급하여 클라이언트로 반환
4. 클라이언트가 API 요청 시 JWT 토큰을 헤더에 포함하면, JwtRequestFilter에서 이를 검증하고 인증
5. 검증된 요청만 처리되어 API 접근이 허용됨

 

JwtService랑 JwtRequestFilter는 더 자세하게 뜯어봐야할거같음.....😭