[unispace] 스프링 시큐리티와 JWT 를 이용한 로그인과 권한 인증 구현

Deeeep Breath·2024년 7월 27일

unispace

목록 보기
4/12
post-thumbnail

1. UserDetails와 UserDetailsImpl

UserDetails란 Spring Security에서 사용자의 핵심 정보를 담는 인터페이스다.

사용자의 정보를 관리하는 User 엔티티가 UserDetails를 직접 구현할 수도 있다.

@Entity
@Getter
@NoArgsConstructor
@Table(name = "USERS")
public class User extends BaseEntity, UserDetails {
    @Id @GeneratedValue
    @Column(name = "USER_ID")
    private Long id;
    ...

그러나 이 경우 User 엔티티가 Spring Security에 직접적으로 의존을 하게 되는 문제가 발생한다. 따라서 난 UserDetailsImpl을 통해 UserDetails를 구현했다.

public class UserDetailsImpl implements UserDetails {
    private final User user;
    private final String username;
    private final String password;
    
	/**
    * 생성자, Spring Security에서 사용할 정보와 실제 User 엔티티를 연결
    */
    public UserDetailsImpl(User user, String username, String password) {
        this.user = user;
        this.username = username;
        this.password = password;
    }
    
	/**
    * Enum으로 정의된 사용자의 권한을 Spring Security가 이해할 수 있는
    GrantedAuthority 객체로 변환
    */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities(){
        UserRole role = user.getUserRole();
        return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
    }

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

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

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

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

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

    @Override
    public boolean isEnabled() {
        return user.isEnabled();
    }
	
    /**
    * 연관된 User 엔티티 객체를 반환
    * UserDetailsImpl 외부에서 필요할 경우 원본 User 객체에 접근할 수 있음
    */
    public User getUser() {
        return user;
    }
}

2. UserDetailsService와 UserDetailsServiceImpl

UserDetailsService는 사용자 정보를 가져오는 메서드를 정의하는 인터페이스다. 마찬가지로 UserDetailsServiceImpl를 통해 구현했다.

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String userLoginId) throws UsernameNotFoundException {
        User user = userRepository.findByLoginId(userLoginId)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
        return new UserDetailsImpl(user, user.getLoginId(), user.getPassword());
    }
}

userRepository를 통해 조회된 User 객체를 기반으로 UserDetailsImpl 객체를 생성하여 반환한다.

사용자가 로그인할 때 입력한 로그인 ID를 기반으로 UserDetailsServiceImpl의 loadUserByUsername 메서드가 호출되어 사용자의 정보를 로드하게 된다.

3. JwtService

JwtService는 JWT 토큰의 생성, 파싱, 검증과 관련된 모든 작업을 담당하는 서비스 계층의 클래스이다.

@Service
public class JwtService {
	/**
    * JWT 토큰 암호화 키
    */
    @Value("${jwt.secret.key}")
    private String secretKey;
	
    /*
    * JWT 토큰을 생성하는 핵심 메서드
    * 사용자의 username(로그인 ID), 사용자 ID(DB의 PK), 권한을 토큰에 포함시킴
    * 토큰의 발행 시간과 만료 시간(24시간 후)을 설정
    * HS256 알고리즘을 사용하여 토큰에 서명
    */
    public String generateToken(UserDetailsImpl user){
        return Jwts.builder()
                .setSubject(user.getUsername())
                .claim("authorities", populateAuthorities(user.getAuthorities()))
                .claim("userId", user.getUser().getId())
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 86400000))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }
	
    /*
    * 사용자의 권한 정보를 문자열로 변환
    */
    private String populateAuthorities(Collection<? extends GrantedAuthority> authorities) {
        Set<String> authoritiesSet = new HashSet<>();
        for(GrantedAuthority authority: authorities) {
            authoritiesSet.add(authority.getAuthority());
        }
        //MEMBER, ADMIN
        return String.join(",", authoritiesSet);
    }
		
    /*
    * JWT 서명에 사용할 키를 생성
    */
    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
	
    /*
    * 주어진 토큰에서 사용자 이름(username)을 추출
    */
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

	/*
    * 토큰에서 사용자 ID를 추출
    */
    public Long extractUserId(String token) {
        return extractClaim(token, claims -> claims.get("userId", Long.class));
    }
	
    /*
    * 토큰의 유효성을 검사
    * 토큰에서 추출한 username과 UserDetails의 username이 일치하는지 확인
    * 토큰이 만료되지 않았는지 확인
    */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername())) && !isTokenExpired(token);
    }
	
    /*
    * 토큰의 만료 여부를 확인
    */
    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }
	
    /*
    * 토큰에서 만료 시간을 추출
    */
    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }
	
    /*
    * 토큰에서 특정 클레임을 추출하는 범용 메서드
    */
    private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }
	
    /*
    * 토큰에서 모든 클레임을 추출
    */
    private Claims extractAllClaims(String token) {
        return Jwts
                .parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

4.JwtAuthenticationFilter와 SecurityConfig

JwtAuthenticationFilter는 모든 HTTP 요청을 가로채어 JWT 토큰의 존재 여부를 확인한다. 토큰이 존재하면 유효성을 검증하고, 유효한 토큰이라면 해당 토큰의 정보를 이용해 Authentication 객체를 생성하여 SecurityContext에 설정한다.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtService jwtService;
    private final UserDetailsServiceImpl userDetailsService;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {
        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String userLoginId;
        if (authHeader == null ||!authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }
        jwt = authHeader.substring(7);
        userLoginId = jwtService.extractUsername(jwt);
        if (userLoginId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(userLoginId);
            if (jwtService.isTokenValid(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        userDetails.getAuthorities()
                );

                authToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );

                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        filterChain.doFilter(request, response);
    }
}

토큰 인증 과정

1. 요청 가로채기

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

모든 요청의 "Authorization" 헤더를 확인한다.

2. 토큰 추출

if (authHeader == null || !authHeader.startsWith("Bearer ")) {
    filterChain.doFilter(request, response);
    return;
}
jwt = authHeader.substring(7);

"Bearer " 접두사를 확인하고 실제 토큰을 추출한다.

3. 사용자 ID 추출

javaCopyuserLoginId = jwtService.extractUsername(jwt);

JwtService를 사용하여 토큰에서 사용자 ID를 추출한다.

4. 토큰 유효성 검증

if (userLoginId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
    UserDetails userDetails = userDetailsService.loadUserByUsername(userLoginId);
    if (jwtService.isTokenValid(jwt, userDetails)) {
        // ... (Authentication 객체 생성 및 설정)
    }
}

추출한 사용자 ID로 UserDetails를 로드한 뒤 JwtService의 isTokenValid 메서드로 토큰의 유효성을 검증한다.

5. Authentication 객체 생성 및 설정

UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
        userDetails, // Principal (UserDetails 객체)
        null, // Credentials (JWT에서는 보통 null)
        userDetails.getAuthorities() // Authorities
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);

유효한 토큰이라면 UsernamePasswordAuthenticationToken 객체를 생성한다.

UsernamePasswordAuthenticationToken은 사용자의 인증 정보(principal, credentials, authorities)를 캡슐화하여 Spring Security의 인증 및 권한 부여 메커니즘에 사용되는 Authentication 객체를 제공한다.

이 객체를 SecurityContextHolder에 설정하여 현재 요청을 인증된 상태로 만든다. SecurityContextHolder는 Spring Security의 핵심 컴포넌트로, 현재 인증된 사용자의 보안 정보를 저장한다.

6. 필터 체인 계속 실행

javaCopyfilterChain.doFilter(request, response);

인증 과정이 완료되면 다음 필터로 요청을 전달한다.

SecurityConfig

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
        http	
        		//CSRF(Cross-Site Request Forgery) 보호 기능 비활성화
                .csrf(AbstractHttpConfigurer::disable)
                
                //모든 HTTP 요청을 허용
                .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
                
                // 세션 관리를 상태 없는(stateless) 방식으로 설정한다. 
                // 이는 서버가 클라이언트의 세션 정보를 저장하지 않음을 의미하며, JWT와 같은 토큰 기반 인증 메커니즘을 사용할 때 일반적으로 사용된다.
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                
                /*
                * 사용자 인증을 처리하기 위해 AuthenticationProvider를 등록한다. 
                * 이 객체는 사용자 인증 정보를 검증하고, 인증 객체를 생성하는 역할을 한다.
                * 이 프로젝트에서는 JWT 기반 인증만을 사용하므로 AuthenticationProvider를 따로 구현하지 않았다.
                * 따라서 추후 서술할 UserService의 authenticate 메서드가 해당 역할을 담당한다.
                */
                .authenticationProvider(authenticationProvider)
                
                //JWT 인증 필터를 추가하여 JWT 토큰을 기반으로 한 인증을 처리
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

5. 컨트롤러 권한 설정

public enum UserRole {
    USER(Authority.USER), ADMIN(Authority.ADMIN);

    private final String authority;

    UserRole(String authority) {
        this.authority = authority;
    }

    public String getAuthority() {
        return this.authority;
    }

    public static class Authority {
        public static final String USER = "USER";
        public static final String ADMIN = "ADMIN";
    }

}
@RestController
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
@SecurityRequirement(name = "Bearer Authentication")
public class AdminController {
    private final CollegeService collegeService;

    /*
    * [POST] 단과대학 추가
    * */
    @PostMapping("/api/admin/university/manage/college")
    public ResponseEntity<SaveResponse> saveCollege(@RequestBody CollegeDto.saveRequest request){
        return ResponseEntity.ok(collegeService.save(request));
    }

    /*
     * [POST] 단과대학 목록 반환
     * */
    @GetMapping("/api/admin/university/manage/college")
    public ResponseEntity<CollegeListResponse> getCollege(Authentication authentication){
        UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
        Long userId = userDetails.getUser().getId();
        return ResponseEntity.ok(collegeService.getCollegesByUserUniversity(userId));
    }
}
  • @PreAuthorize : 메서드 호출 전에 권한을 확인하는 Spring Security의 메서드 보안 기능

  • getCollege(Authentication authentication) : 사용자가 특정 API 엔드포인트에 접근할 때, Spring Security는 해당 요청의 컨텍스트에서 Authentication 객체를 찾아 매개변수로 전달한다. 이를 통해 컨트롤러 메서드 내에서 현재 인증된 사용자 정보를 사용할 수 있게 된다.

6. 로그인 관련 메서드

@Service
@RequiredArgsConstructor
@Transactional
public class UserService {
    private final JwtService jwtService;
    private final AuthenticationManager authenticationManager;
    private final UserDetailsServiceImpl userDetailsService;

    public AuthenticationResponse authenticate(AuthenticationRequest request){
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getUserId(),
                        request.getPassword()
                )
        );

        UserDetailsImpl userDetails = (UserDetailsImpl) userDetailsService.loadUserByUsername(request.getUserId());
        String jwtToken = jwtService.generateToken(userDetails);
        return AuthenticationResponse.builder()
                .accessToken(jwtToken)
                .userId(userDetails.getUser().getId()).build();
    }
    
    @Data
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class AuthenticationRequest {
        private String userId;
        private String password;
    }
    
    @Data
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public static class AuthenticationResponse{
        @JsonProperty("access_token")
        private String accessToken;
        private Long userId;
    }
}

사용자 인증 프로세스
사용자가 제공한 로그인 ID와 비밀번호를 기반으로 인증을 수행합니다.

AuthenticationManager
AuthenticationManager는 Spring Security에서 사용자 인증을 처리하는 핵심 인터페이스다. 이 인터페이스는 다음과 같은 기능을 제공한다.

  • 인증 요청 수행
  • 사용자의 자격 증명 검증에 필요한 메커니즘 제공

사용자가 로그인할 때, AuthenticationManager는 해당 요청을 적절한 AuthenticationProvider에 위임하여 인증을 수행한다.

이 프로젝트에서는 AuthenticationProvider를 따로 구현하지 않았으므로 Spring Security가 기본적으로 제공하는 구현을 이용해 인증을 처리한다. 일반적으로 DaoAuthenticationProvider가 이 역할을 수행한다.

DaoAuthenticationProvider
DaoAuthenticationProvider는 다음과 같은 과정으로 인증을 수행한다.

  1. UserDetailsService를 사용하여 데이터베이스에서 사용자 정보를 로드
  2. 로드된 사용자 정보의 비밀번호와 입력된 비밀번호를 비교
  3. 인증 성공 시, GrantedAuthority를 사용하여 사용자의 권한을 부여

7. 전체 흐름

profile
안녕하세요!

0개의 댓글