본문 바로가기
Project

[Stock] 회원 관리 - 인증 구현

by sangyunpark 2023. 9. 25.

JWTFilter 구현

package com.example.stock.security;

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.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;


@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    public static final String TOKEN_HEADER = "Authorization"; // 토큰 헤더 키
    public static final String TOKEN_PREFIX = "Bearer "; // value : 인증 Prefix

    private final TokenProvider tokenProvider; // 토큰의 유효성 검증

    // 서블릿 거치기 전에 필터를 먼저 거친다.
    // 요청이 들어올때마다 컨트롤러 보다 먼저 시작되면서
    // 요청의 헤더에 토큰이 있는지 확인하고, 토큰이 유효하다면 인증정보를 context에 담는다.
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = resolveTokenFromRequest(request); // request로 부터 헤더를 꺼내온다.
        
        if(StringUtils.hasText(token) & tokenProvider.validateToken(token)){ // 토큰 유효성 검증
            Authentication auth = tokenProvider.getAuthenticate(token); // 사용자 정보, 사용자 권한 정보 포함
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        // 유효하지 않을 경우
        filterChain.doFilter(request, response); // 필터가 연속적으로 실행될 수 있게 해줌
    }

    private String resolveTokenFromRequest(HttpServletRequest request){
        String token = request.getHeader(TOKEN_HEADER);

        if(!ObjectUtils.isEmpty(token) && token.startsWith(TOKEN_PREFIX)){ // 토큰 존재 & 토큰 프리픽스로 시작
            return token.substring(TOKEN_PREFIX.length()); // 토큰부위 도려내주기
        }

        return null;
    }


}

Controller로 요청이 들어오기 전에, 제일 먼저 앞단에서 Filter를 거치고, Servlet을 거치고, Interceptor를 거치고 AOP를 거치고 Controller로 가게 된다. 응답도 마찬가지이다.

 

OnePerRequestFilter : 한 요청당 한번 필터가 실행이된다.

필터에서 요청이 들어올때마다 요청에 토큰이 포함되어 있는지 확인하고, 그 토큰이 유효한지 아닌지에 대해서도 판별해준다.

 

TokenProvider

package com.example.stock.security;


import com.example.stock.service.MemberService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.Date;
import java.util.List;

@Component
@RequiredArgsConstructor
public class TokenProvider {

    private static final String KEY_ROLES = "roles"; // 상수값은 요렇게
    private static final long TOKEN_EXPIRE_TIME = 1000 * 60 * 60; // 1hour
    @Value("{spring.jwt.secret}")
    private String secretKey;

    private final MemberService memberService;

    /*
    * 토큰을 생성(발급)
    * @param username
    * @param rules
    * @return
    * */
    public String generateToken(String username, List<String> rolse) {

        // 사용자 권한 정보를 저장하기 위한 claim 생성
        Claims claims = Jwts.claims().setSubject(username);
        claims.put(KEY_ROLES, rolse); // key,value로 저장

        Date now = new Date();

        Date expiredDate = new Date(now.getTime() + TOKEN_EXPIRE_TIME);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now) // 토큰 생성 시간
                .setExpiration(expiredDate) // 토큰 만료 시간
                .signWith(SignatureAlgorithm.HS512, secretKey) // 사용할 암호화 알고리즘, 비밀키
                .compact();
    }

    public Authentication getAuthenticate(String jwt){ // jwt 토큰으로 부터 인증 정보 가져오기
        UserDetails userDetails = memberService.loadUserByUsername(getUsername(jwt));

        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); // 스프링 지원
    }

    public String getUsername(String token){
        return parseClaims(token).getSubject();
    }

    public boolean validateToken(String token){ // 토큰이 유효한가
        if(!StringUtils.hasText(token)){ // 빈값인 경우
            return false;
        }

        return parseClaims(token).getExpiration().before(new Date()); // 토큰의 만료시간이 현재 시간의 이전인지 아닌지에 대해 알려준다.
    }

    private Claims parseClaims(String token){

        try{
            return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
        }catch(ExpiredJwtException e){
            return e.getClaims();
        }
    }
 }

getAuthenticatin 메소드를 추가로 선언해준다.(JWT 토큰으로부터 인증 정보를 가져오는 메소드)

 

filter 실행 -> 요청 헤더 토큰 확인 -> 토큰 유효시 인증정보를  Context에 담는다.

 

SecurityConfiguration

package com.example.stock.security;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

@Slf4j
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfiguration implements AuthenticationManager{

    private final JwtAuthenticationFilter authenticationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.httpBasic(HttpBasicConfigurer::disable) // RESTFul api
                .csrf(CsrfConfigurer::disable)
                .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authorize ->
                        authorize.
                                requestMatchers(
                                        new AntPathRequestMatcher("/**/signup"),
                                        new AntPathRequestMatcher("/**/signin")
                                ).permitAll() // signin, signup에는 모든 권한을 주겠다.
                        )
                .addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class);


        return http.build();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web -> web.ignoring(). // 인증 정보를 따로 필요로 하지 않는 경로
                requestMatchers(
                        new AntPathRequestMatcher("/h2-console/**"),
                        new AntPathRequestMatcher("/company/**")
                )
        );
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        return authentication;
    }
}

 

 

권한 제약을 거는 방법

 

권한 Enum

package com.example.stock.model.constants;

public enum Authority {
    ROLE_READ,
    ROLE_WRITE;
}

ROLE_ 뒷부분 : Spring Security에서 지원해주는 기능을 사용하고자 함

 

 

@GetMapping // 회사 리스트 조회
@PreAuthorize("hasRole('READ')")
public ResponseEntity<?> searchCompany(final Pageable pageable){
    // final 키워드로 임의 변환 방지
    // Pagination 요청 정보를 담기위한 추상 인터페이스

    Page<CompanyEntity> companies = companyService.getAllCompany(pageable);
    // 회사명은 전부 다 가져와야 하는가?
    // 가져와야하는 데이터가 클수록 네트워크 대역푝 증가
    // client 단에서 한번에 보여줄 수 있는 아이템의 갯수는 정해져 있다!
    // paging 사용

    return ResponseEntity.ok(companies);
}

@PostMapping // 회사 저장
//@PreAuthorize("hasRole('WRITE')") // 쓰기권한만 있는 Authroity enum 권한
public ResponseEntity<?> addCompany(@RequestBody Company request){
    String ticker = request.getTicker().trim();
    if(ObjectUtils.isEmpty(ticker)){
        throw new RuntimeException("ticker is empty");
    }

    Company company = this.companyService.save(ticker);
    companyService.addAutocompleteKeyword(company.getName());
    return ResponseEntity.ok(company);
}

@PreAuthorize를 사용해서 입맞에 맡게 권한을 부여해준다.