Spring/인증_인가

[Spring] JWT 구현 순서 with. Spring Security

초코chip 2024. 11. 11. 22:56

최종 순서 요약

이 순서를 따라가면 Spring Security와 JWT 기반 인증을 효율적이고 최신 방식으로 구현 가능!! 🚀

  1. User 엔티티와 리포지토리 구현: 사용자 데이터 저장 및 조회를 위한 기반.
  2. UserPrincipal과 CustomUserDetailsService 구현: Spring Security와 사용자 데이터 연동.
  3. JwtUtil 구현: JWT 생성, 검증, 파싱 로직
  4. JwtRequestFilter 구현: Spring Security 필터 체인에 JWT 기반 인증 추가.
  5. Spring Security 설정: 필터 체인, 세션 관리, 접근 제어 설정.
  6. 인증 컨트롤러 구현: 로그인 및 JWT 발급/검증 API 제공.

 

1. User 엔티티와 리포지토리 구현

  • 목적: 애플리케이션에서 사용자 정보를 데이터베이스에 저장하고 관리하기 위함.

구현

User 엔티티

사용자 정보(이메일, 이름, 역할 등)를 정의. (entity/User.java)

package pda5th.backend.theOne.entity;

@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id; // 유저ID

    @Column(nullable = false, length = 50)
    private String name; // 이름

    @Column(unique = true, nullable = false, length = 100)
    private String email; // 이메일

    private String password; // 프로필 이미지 URL

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role; // 역할 (ADMIN, USER)

    @Column(updatable = false)
    private LocalDateTime createdAt; // 생성 날짜

    // 주요 데이터만 설정하는 생성자 제공
    @Builder
    public User(String name, String email, String password, Role role) {
        this.name = name;
        this.email = email;
        this.password = password;
        this.role = role;
        this.createdAt = LocalDateTime.now(); // 생성 시간 자동 설정
    }
}

 

UserRepository

 사용자 검색(이메일 기반) 및 CRUD 작업을 수행하는 JpaRepository.  (repository/UserRepository.java)

package pda5th.backend.theOne.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import pda5th.backend.theOne.entity.User;
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Integer> {
    Optional<User> findByEmail(String email);
}

 

 

2. UserPrincipal과 CustomUserDetailsService 구현

  • 목적: Spring Security의 인증 시스템과 연동하여 사용자 정보를 로딩 및 관리
    • Spring Security는 UserDetails 인터페이스 구현체로 사용자 인증을 처리

구현:

UserPrincipal

  • UserDetails 인터페이스를 구현하여 User 엔티티를 Spring Security와 연결.
  • 사용자 역할 및 권한 관리.
  • common/security/UserPrincipal.java
package pda5th.backend.theOne.common.security;

@Getter
@RequiredArgsConstructor
public class UserPrincipal implements UserDetails {
    private final User user;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.singleton(() -> "ROLE_" + user.getRole());
    }

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

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

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

CustomUserDetailsService

  • UserRepository를 통해 User 엔티티를 조회하고, UserPrincipal을 반환.
  • Spring Security가 사용자 인증 시 호출.
  • common/security/CustomUserDetailsService.java
package pda5th.backend.theOne.common.security;

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        User user = userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email));

        return new UserPrincipal(user); // UserPrincipal 반환
    }
}

 

 

3. JwtUtil 구현

  • 목적: JWT 생성, 파싱, 검증 로직을 캡슐화하여 토큰 기반 인증을 처리.
    • 토큰에서 사용자 정보를 추출하거나, 토큰의 유효성을 확인.

 구현:

  • JWT 생성: 사용자 정보를 기반으로 JWT 토큰 생성.
  • JWT 검증: 토큰의 유효성을 확인(만료 시간, 서명).
  • 사용자 정보 추출: 토큰에서 사용자 이름 권한 정보를 추출.

common/jwt/util/JwtUtil.java

package pda5th.backend.theOne.common.jwt.util;

@Component
public class JwtUtil {

    // application.yml에서 주입된 값
    @Value("${jwt.secret}")
    private String SECRET_KEY;

    @Value("${jwt.expiration}")
    private long EXPIRATION_TIME;

    // 비밀 키를 Key 객체로 변환 (최신 JJWT에서 권장)
    private Key getSigningKey() {
        return Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
    }

    /**
     * 사용자 이름(Username)을 기반으로 JWT 토큰을 생성
     * @param username 사용자 이름
     * @return 생성된 JWT 토큰
     */
    public String generateToken(String username) {
        Map<String, Object> claims = new HashMap<>();
        return createToken(claims, username);
    }

    /**
     * JWT 토큰 생성 메서드
     * @param claims 클레임(Claims)
     * @param subject 사용자 식별 정보 (주로 사용자 이름)
     * @return 생성된 JWT 토큰
     */
    private String createToken(Map<String, Object> claims, String subject) {
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(new Date(System.currentTimeMillis())) // 발행 시간
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 만료 시간
                .signWith(getSigningKey(), SignatureAlgorithm.HS256) // 최신 방식: Key 객체와 알고리즘 설정
                .compact();
    }

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

    /**
     * JWT 토큰에서 만료 시간을 추출
     * @param token JWT 토큰
     * @return 토큰 만료 시간
     */
    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    /**
     * JWT 토큰에서 특정 클레임(Claim)을 추출
     * @param token JWT 토큰
     * @param claimsResolver 클레임을 추출하는 함수
     * @return 추출된 클레임 값
     */
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    /**
     * JWT 토큰에서 모든 클레임(Claims)을 추출
     * @param token JWT 토큰
     * @return 클레임 객체
     */
    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey()) // 최신 방식: Key 객체로 서명 검증
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * JWT 토큰이 만료되었는지 확인
     * @param token JWT 토큰
     * @return 만료 여부 (true: 만료됨, false: 유효함)
     */
    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    /**
     * JWT 토큰의 유효성을 검증
     * @param token JWT 토큰
     * @param username 검증 대상 사용자 이름
     * @return 유효 여부 (true: 유효함, false: 유효하지 않음)
     */
    public boolean validateToken(String token, String username) {
        final String extractedUsername = extractUsername(token);
        return (extractedUsername.equals(username) && !isTokenExpired(token));
    }
}

 

 

4. JwtRequestFilter 구현

  • 목적: Spring Security의 필터 체인에 JWT 인증을 추가.
    • 클라이언트 요청의 헤더에서 JWT를 추출하고, 이를 검증하여 SecurityContext에 인증 정보를 설정.

 구현:

  • JWT 추출 및 검증: 요청의 Authorization 헤더에서 JWT 추출.
    • JwtUtil을 사용하여 토큰 검증.
  • SecurityContext 설정: UserPrincipal을 기반으로 인증 객체 생성 후, SecurityContext에 설정.

common/jwt/util/JwtUtil.java

package pda5th.backend.theOne.common.jwt.filter;

@Component
@RequiredArgsConstructor
public class JwtRequestFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final CustomUserDetailsService customUserDetailsService; // CustomUserDetailsService 사용

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

        try {
            // 1. 요청에서 JWT 추출
            String jwt = extractJwtFromRequest(request);

            // 2. JWT가 존재하고 SecurityContext에 인증 정보가 없는 경우
            if (jwt != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                String username = jwtUtil.extractUsername(jwt); // JWT에서 사용자 이름 추출

                if (username != null) {
                    UserPrincipal userPrincipal = (UserPrincipal) customUserDetailsService.loadUserByUsername(username);

                    // 3. JWT 유효성 검증
                    if (jwtUtil.validateToken(jwt, userPrincipal.getUsername())) {
                        setAuthentication(userPrincipal, request); // 인증 설정
                    }
                }
            }
        } catch (Exception e) {
            // 예외 발생 시 401 상태 코드와 에러 메시지 반환
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Unauthorized: " + e.getMessage());
            return; // 필터 체인 중단
        }

        // 다음 필터로 요청 전달
        chain.doFilter(request, response);
    }

    /**
     * 1. Authorization 헤더에서 JWT 추출
     *
     * @param request HttpServletRequest 객체
     * @return JWT 문자열 또는 null
     */
    private String extractJwtFromRequest(HttpServletRequest request) {
        final String authorizationHeader = request.getHeader("Authorization");

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            return authorizationHeader.substring(7); // "Bearer " 이후의 JWT 반환
        }

        return null; // 유효한 JWT가 없는 경우 null 반환
    }

    /**
     * 2. SecurityContext에 인증 정보 설정
     *
     * @param userPrincipal 사용자 인증 정보
     * @param request HttpServletRequest 객체
     */
    private void setAuthentication(UserPrincipal userPrincipal, HttpServletRequest request) {
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                userPrincipal, null, userPrincipal.getAuthorities());

        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
}

 

5. Spring Security 설정

  • 목적: 애플리케이션의 보안 규칙을 설정.
    • Stateless 인증(JWT 기반)과 특정 엔드포인트의 접근 권한을 정의.

 구현:

  1. SecurityFilterChain:
    • JWTRequestFilter를 필터 체인에 추가.
    • CSRF 비활성화, Stateless 세션 설정.
  2. PasswordEncoder: 비밀번호를 암호화/복호화하기 위한 BCryptPasswordEncoder 설정.
  3. AuthenticationManager: CustomUserDetailsService와 연동하여 인증 관리.

 

config/SecurityConfig.java

package pda5th.backend.theOne.config;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtRequestFilter jwtRequestFilter; // JWT 필터 주입
    private final CustomUserDetailsService customUserDetailsService; // CustomUserDetailsService 주입

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable()) // CSRF 비활성화 (JWT 사용 시 필요 없음)
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 비활성화 (Stateless)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/authenticate", "/swagger-ui/**", "/v3/api-docs/**", "/api/auth/login", "/api/users/signup", "/api/timer/*/events", "/api/questions/stream").permitAll() // 인증 필요 없는 경로 설정
                        .anyRequest().authenticated() // 나머지 요청은 인증 필요
                )
                .addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); // JWT 필터를 인증 필터 전에 추가

        return http.build(); // SecurityFilterChain 객체 생성
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.addAllowedOrigin("http://localhost"); // 허용할 Origin 설정
        configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용
        configuration.addAllowedHeader("*"); // 모든 헤더 허용
        configuration.setAllowCredentials(true); // 인증 정보 포함 허용

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(); // 비밀번호 암호화를 위한 BCrypt 사용
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager(); // AuthenticationManager 빈 등록
    }
}

 

6. 인증 컨트롤러 구현

  • 목적: 사용자 인증 요청을 처리하고, JWT 토큰을 반환.
    • 로그인, 토큰 검증 등의 인증 관련 API 제공.

 구현:

  • 로그인 엔드포인트: 클라이언트가 이메일/비밀번호를 제출하면, 이를 인증하고 JWT 토큰을 반환.
  • 토큰 검증 엔드포인트 (선택): 클라이언트가 제출한 JWT 토큰의 유효성을 확인.

 

controller/AuthControler.java

package pda5th.backend.theOne.controller;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth") // 모든 인증 관련 API를 '/api/auth' 경로로 그룹화
public class AuthController {

    private final AuthenticationManager authenticationManager; // Spring Security 인증 관리자
    private final JwtUtil jwtUtil; // JWT 유틸리티 클래스

    /**
     * 로그인하여 JWT 토큰을 발급받는 엔드포인트
     *
     * @param authRequest 사용자 로그인 요청 (username, password)
     * @return JWT 토큰 응답
     * @throws Exception 인증 실패 시 예외 발생
     */
    @PostMapping("/login")
    @Operation(summary = "로그인 및 JWT 토큰 발급", description = "사용자 이름과 비밀번호를 입력하여 JWT 토큰을 발급받습니다.")
    public AuthResponse login(@RequestBody AuthRequest authRequest) throws Exception {
        try {
            // 사용자 인증 수행
            authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(authRequest.username(), authRequest.password())
            );
        } catch (AuthenticationException e) {
            throw new RuntimeException("Invalid username or password", e); // 인증 실패 시 예외 처리
        }

        // 인증 성공 시 JWT 토큰 생성 및 반환
        String token = jwtUtil.generateToken(authRequest.username());
        return new AuthResponse(token); // JWT 토큰을 담은 응답 객체 반환
    }

    /**
     * JWT 토큰의 유효성을 확인하는 엔드포인트
     *
     * @return 간단한 메시지 (JWT가 유효한 경우)
     */
    @GetMapping("/validate-token")
    @Operation(summary = "JWT 토큰 유효성 확인", description = "발급받은 JWT 토큰이 유효한지 확인하고, 사용자 정보를 반환합니다.")
    public ResponseEntity<UserInfoResponse> validateToken(@AuthenticationPrincipal UserPrincipal userPrincipal) {
        Integer userId =  userPrincipal.getUser().getId();
        String username = userPrincipal.getUser().getName();
        return ResponseEntity.ok(new UserInfoResponse(userId, username));
    }

}

 

 

 

 

 

 

'Spring > 인증_인가' 카테고리의 다른 글

[Spring] JWT를 이용한 인증,인가 동작 원리  (0) 2024.11.11