본문 바로가기
카테고리 없음

스프링 핵심가이드) 북스터디 8주차 : 13장 서비스의 인증과 권한 부여

by son_i 2023. 11. 12.
728x90

13장 서비스의 인증과 권한 부여

 

13.1 보안 용어 이해

13.1.1 인증

- 인증(authentication)은 사용자가 누구인지 확인하는 단계

 ex) 로그인

  로그인은 DB에 등록된 아이디와 pw를 사용자가 입력한 아이디와 pw와 비교해서 일치여부를 확인하는 과정.

  로그인 성공시 애플리케이션 서버는 응답으로 사용자에게 토큰(token)을 전달.

  로그인 실패시 토큰을 전달받지 못해 원하는 리소스에 접근할 수 없음.

 

13.1.2 인가

인가(authorization)는 위의 인증을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접근 할 때 사용자가 해당 리소스에 접근 할 권리가 있는지를 확인하는 과정.

 ex) 로그인한 사용자가 특정 게시판에 접근해 글을 보려고 할 때 게시판 접근 등급에 따라 접근 허가/거부

 

13.1.3 접근 주체

접근 주체(principal)는 애플리케이션의 기능을 사용하는 주체를 의미.

 ex) 사용자 / 디바이스/ 시스템

  애플리케이션은 인증 과정을 통해 접근 주체가 신뢰할 수 있는지 확인,

  인가 과정을 통해 접근 주체에게 부여된 권한을 확인하는 과정을 거침.

 


13.2 스프링 시큐리티

- 스프링 시큐리티는 애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링 하위 프로젝트 중 하나.

보안과 관련된 많은 기능 제공.

 


13.3 스프링 시큐리티의 동작 구조

스프링 시큐리티는 서블릿 필터(Sevlet Filter)를 기반으로 동작하며 DispatcherServlet 앞에 필터가 배치되어있다.

- 필터 체인 : 서블릿 컨테이너에서 관리하는 ApplicationFilterChain의미

  클라이언트 -> 애플리케이션 요청 보내면 서블릿 컨테이너는 URI를 확인해서 필터와 서블릿 매핑.

  스프링 시큐리티는 사용하고자 하는 필터체인을 서블릿 컨테이너의 필터 사이에서 동작시키기 위해  DelegatingFilterProxy를 사용함.

 

- DelegatingFilterProxy는 서블릿 컨테이너의 생명주기와 스프링 애플리케이션 컨텍스트 (Application Context) 사이에서 다리 역할을 수행하는 필터 구현체.

 표준 서블릿 필터를 구현하고 있으며, 역할을 위임할 필터체인 프록시(FilterChainProxy)를 내부에 가지고 이씀.

 필터체인 프록시는 스프링 부트의 자동 설정에 의해 자동 생성.

 

- FilterChain Proxy : 스프링 시큐리티에서 제공하는 필터로 보안 필터체인(SecurityFilterChain)을 통해 많은 보안 필터(Security Filter)를 사용할 수 있다. 필터체인 프록시에서 사용할 수 있는 보안 필터 체인은 List 형식으로 담을 수 있게 설정돼 있어 URI 패턴에 따라 특정 보안필터 체인을 선택해서 사용.

 

보안필터 체인에서 사용하는 필터는 여러 종류가 있으며, 각 필터마다 실행되는 순서가 다름.

공식문서에 따른 필터 실행 순서 - p.376 참고

 

보안 필터체인은 WebSecutiryConfigurerAdapter 클래스를 상속받아 설정 가능.

필터체인 프록시는 여러 보안 필터체인을 가질 수 있는데, 여러 보안 필터체인을 만들기 위해서는 WebSecurityConfigurerAdapter 클래스를 상속받는 클래스를 여러개 생성하면 된다.

 2개 이상의 클래스를 생성했을 때 똑같은 설정으로 우선순위 지정시 예외가 발생하기 때문에

 상속받은 클래스에서 @Order 어노테이션을 지정해 순서를 정의하는 것이 중요하다.

 

별도의 설정이 없으면 스프링 시큐리티에서는 SecurityFilterChain에서 사용하는 필터 중

UsernamePasswordAuthenticationFilter를 통해 인증을 처리.

 

- UsernamePasswordAuthenticationFilter를 통한 인증과정.

1. 클라이언트로부터 요청을 받으면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되고

그 중 UsernamePasswordAuthenticationFilter에서 인증을 처리.

 

2. AuthenticationFilter는 요청 객체(HttpServletRequest)에서 username과 password를 추출해서 토큰 생성

 

3. AuthenticationManager에게 토큰 전달. AuthenticationManager는 인터페이스이며,

일반적으로 사용되는 구현체는 ProviderManager.

 

4. ProviderManager는 인증을 위해 AuthenticationProvider로 토큰 전달.

 

5. AuthenticationProvider는 토큰의 정보를 UserDetailService에 전달.

 

6. UserDetailService는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체를 생성.

 

7. 생성된 UserDetails 객체는 AuthenticationProvider로 전달되며, 해당 Provider에서 인증을 수행하고 성공하게 되면 ProviderManager로 권한을 담은 토큰 전달.

 

8. ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달.

 

9. AuthentiationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장.

 

 

-> 위 과정에서 사용된 usernamePasswordAuthenticationFilter는 접근 권한을 확인하고 인증이 실패할 경우 로그인 폼이라는 화면을 보내는 역할을 수행.

 

 이 책에서 실습하는 프로젝트는 하면이 없는 RESTful 애플리케이션이기 때문에 다른 필터에서 인증 및 인가 처리를 수행해야함. 이 책에서는 JWT 토큰을 사용해 인증을 수행할 것이기 때문에 JWT관련 필터 생성하고 UsernamePasswordAuthenticationFilter 앞에 배치해서 먼저 인증을 수행할 수 있게 설정할 것임.

 


13.4 JWT

JWT(Json Web Token)은 당사자 간 정보를 JSON 형태로 안전하게 전송하기 위한 토큰.

JWT는 URL로 이용할 수 있는 문자열로만 구성돼 있으며, 디지털 서명이 적용되어 있어 신뢰할 수 있음.

JWT는 주로 서버와의 통신에서 권한 인가를 위해 사용됨.

URL에서 사용할 수 있는 문자열로만 구성돼 있기 때문에 HTTP 구성요소 어디든 위치할 수 있음.

 

13.4.1 JWT의 구조

JWT는 .으로 구분된 세부분으로 구성.

- 헤더 (Header)

- 내용 (Payload)

- 서명 (Signature)

 

JWT는 일반적으로 아래와 같은 형식

xxxxx . yyyyy. zzzzz
헤더     내용    서명

 

- 헤더

JWT의 헤더는 검증과 관련된 내용을 담고 있음.

헤더에는 두 가지 정보를 포함

1. alg

2. typ

{
	"alg" : "HS256",
    "typ" : "JWT"
}

- alg 속성에서는 해싱 알고리즘을 지정. 

  해싱 알고리즘은 보통 SHA156 또는 RSA를 사용하며, 토큰을 검증할 때 사용되는 서명 부분에서 사용.

  예제에 있는 HS256은 'HMACSHA256' 알고리즘을 사용한다는 의미.

 

- typ 속성에서는 토큰의 타입 지정.

 

이렇게 완성된 헤더는 Base64Url 형식으로 인코딩 돼 사용

 

- 내용(Payload)

JWT의 내용에는 토큰에 담는 정보를 포함. 이곳에 포함된 속성들은 클레임(Claim)이라 하며, 크게 세 가지로 분류

1. 등록된 클레임(Registered Claims)

2. 공개 클레임(Public Claims)

3. 비공개 클레임(Private Claims)

 

등록된 클레임은 필수는 아니나 토큰에 대한 정보를 담기 위해 이미 이름이 정해져 있는 클레임을 뜻함.

등록된 클레임은 다음과 같이 정의.

 

- iss : JWT의 발급자(Issuer) 주체를 나타냄. iss의 값은 문자열이나 URI를 포함하는 대소문자를 구분하는 문자열.

- sub : JWT의 제목(Subject)

- aud : JWT의 수신인(Audience). JWT를 처리하려는 각 주체는 해당 값으로 자신을 식별.

  요청을 처리하는 주체가 'aud' 값으로 자신을 식별하지 않으면 JWT는 거부됨.

- exp : JWT의 만료시간(Expiration). 시간은 NumericDate 형식으로 지정해야함.

- nbf : 'Not Before' 의미.

- iat : JWT가 발급된 시간(issued at).

- jti : JWT이 식별자 (JWT ID). 주로 중복처리를 방지하기 위해 사용.

 

공개 클레임은 키 값을 마음대로 정의할 수 있음.

다만 충돌이 발생하지 않을 이름으로 설정해야함.

 

비공개 클레임은 통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임을 의미.

 

{
	"sub" : "wikibooks payload",
    "exp" : "1602076408",
    "userId" : "wikibooks",
    "username" "flature"
}

이렇게 완성된 내용은 Base64Url 형식으로 인코딩 되어 사용.

 

- 서명

JWT의 서명 부분은 인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성갑을 가져와 생성.

예를 들어, JMAC SHA256 알고리즘을 사용해서 서명을 생성한다면 아래와 같은 방식으로 생성.

HMACSHA256 {
	base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    secret
}

서명은 토큰의 값들을 포함해서 암호화하기 때문에 메세지가 도중에 변경되지 않았는지 확인할 때 사용.

 

13.4.2 JWT 디버거 사용하기

JWT 공식 사이트에서는 더욱 쉽게 JWT를 생성해 볼 수 있음.

웹 브라우저에서 https://jwt.io/#debugger-io 접속해서 토큰 값 입력시 확인 가능.

 

encoded 와 decoded 내용이 일치하는지 사이트에서 확인할 수 있고, Decoded 내용을 변경하면 Encoded 콘텐츠가 자동으로 반영됨.

 


13.5 스프링 시큐리티와 JWT 적용

1. 의존성 추가. 

 

<pom.xml>

<dependency>
	<groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<dependency>
	<groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

 

<build.gradle>

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt:0.9.1'

 

스프링 시큐리티는 기본적으로 UsernamePasswordAuthenticationFilter를 통해 인증을 수행하도록 구성.

이 필터에서는 인증이 실패하면 로그인 폼이 포함된 화면을 전달하게 되는데, 이 실습 프로젝트에는 화면이 없음.

따라서 JWT를 사용하는 인증 필터를 구현하고 UsernamePasswordAAuthenticationFilter 앞에 인증 필터를 배치해서 인증 주체를 변경하는 작업을 수행하는 방식으로 구성.

 

13.5.1 UserDetails와 UserDetailsService 구현

사용자 정보를 담는 Entity 생성

 

<User>

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table
public class User implements UserDetails {
	 @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MANAGER_ID")
    private long id;
    
    @Column(nullable = false, unique = true)
    private String uid;
    
    @JsonProperty(access = Access.WRITE_ONLY)
    @Column(nullable = false)
    private String password;
    
    @Column(nullable = false)
    private String name;
    
    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();
    
    @Override
    public Collection<? extends GrantedAythority> getAuthorities() {
    	return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Colletors.toList());
	}
    
    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public String getUsername() {
    	return this.uid;
    }
    
    @JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonExpired() {
    	return true;
    }
	@JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

	@JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
	@JsonProperty(access = Access.WRITE_ONLY)
    @Override
    public boolean isEnabled() {
        return true;
    }
}

User 엔티티는 UserDetails 인터페이스를 구현.

UserDetails는 UserDetailServbice를 통해 입력된 로그인 정보를 가지고 데이터베이스에서 사용자 정보를 가져오는 역할을 수행.

 

UserDetails 인터페이스가 가지고 있는 메서드.

public interface UserDetails extends Serializable {
	Collection<? extends GrantedAuthority> getAuthorities();
    
    String getPassword();
    
    String getUsername();
    
    boolean isAccountNonExpired();
    
    boolean isAccountNonLocked();
    
    boolean isCredentialNonExpired();
    
    boolean isEnabled();
}

 

- getAuthorities() : 계정이 가지고 있는 권한 목록을 리턴.

- getPassword() : 계정의 비밀번호를 리턴.

- getUsername() : 계정의 이름을 리턴. 일반적으로 아이디를 리턴.

- isAccountNonExpired() : 계정이 만료됐는지 리턴. true는 만료되지 않았다는 의미.

- isAccountNonLocked() : 계정이 잠겨있는지 리턴. true는 잠기지 않았다는 의미.

- isCredentialNonExpired() : 비밀번호가 만료됐는지 리턴. true는 만료되지 않았다는 의미.

- isEnabled() : 계정이 활성화 돼있는지 리턴. true는 활성화 상태를 의미.

 

계정 상태 변경은 다루지 않을 것이라 true로 리턴. 이 엔티티는 앞으로 토큰을 생성할 때 토큰의 정보로 사용될 정보와 권한 정보를 갖게됨.

 

<UserRepository>

public interface UserRepository extends JpaRepository<User, Long> {
	User getByUid(String uid);
}

 

UserRepository를 작성하는 것은 기존 리포지토리 작성 방법과 동일.

현재 ID 값은 인덱스 값이기 때문에 id 값을 토큰 생성 정보로 사용하기 위해 getByUid() 메서드를 생성함.

 

리포지토리를통해 User 엔티티의 id를 가져오는 서비스 생성

 

<UserDetailsServiceImpl> 

@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UseDetailsService {
	private final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
    
    private final UserRepository userRepository;
    
    @Override
    public UserDetails loadUserByUsername(String username) {
    	LOGGER.info("[loadUserByUsername] loadUserByUsername 수행. username : {}", username);
        return userRepository.getByUid(username);
    }
}

 

- 3번 줄 : UserDetailsService 인터페이스를 구현하도록 설정.

  UserDetailsService는 loadUserByUsername() 메서드를 구현하도록 정의.

public interface UserDetailsService {
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundExcpetion;
}

 

 

UserDetails는 스프링 시큐리티에서 제공하는 개념으로, UserDetails의 username은 각 사용자를 구분할 수 있는 ID를 의미.

3번 줄의 메서드를 보면 username을 가지고 UserDetails 객체를 리턴하게끔 정의돼있는데, 

UserDetails의 구현체로 User 엔티티를 생성했기 때문에 User 객체를 리턴하게끔 구현한 것.

 

13.5.2 JwtTokenProvider 구현

JWT 토큰을 생성하는데 필요한 정보를 UserDetails에서 가져올 수 있기 때문에 JWT 토큰을 생성하는 JwtTokenProvider를 생성.

 

<JwtTokenProvider>

@Component
@RequiredArgsConstructor
public claass JwtTokenProvider {
	private final Logger LOGGER = LoggerFactory.getLoccer(JwtTokenProvider.class);
    private final UserDetailsService userDetailsService;
    
    @Value("${springboot.jwt.secret}")
    private String secretKey = "secretKey";
    private final long tokenValidMillisecond = 1000L * 60 * 60;
    
    @PostConstruct
    protected void init() {
    	LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
        
        Logger.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
    }
    
    public String createToken(String userUid, List<String> roles) {
    	LOGGER.info("[createToken] 토큰 생성 시작");
        Claims claims = Jwts.claims().setSubject(userUid);
        claims.put("roles", roles);
        Date now = new Date();
        
        String token = Jwts.builder()
        	.setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
            
            LOGGER.info("[createToken] 토큰 생성 완료");
            return token;
    }
    
    public Authentication getAuthentication(String token) {
    	LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName : {}",
        	userDetails.getUsername());
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }
    
    public String getUsername(String token) {
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
        String info = Jwts.parser().setSigningKey(secretKey)
        				.parseClaimsJws(token).getBody().getSubject();
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
        return info;
    }
    
    public String resolveToken(HttpServletRequest request) {
    	LOGGER.info("[resolveToken] HTTP 헤더에서 Token값 추출");
        return request.getHeader("X-AUTH-TOKEN");
    }
    
    public boolean validateToken(String token) {
    	LOGGER.info("[validateToken] 토큰 유효 체크 시작");
        
        try {
        	Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
        	LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
            return false;
        }
    }
}

- 토큰 생성하기 위해 secretKey가 필요하므로 8~9번째 줄에서 secretKey 값을 정의. 

  @Value의 값은 application.properties 파일에서 다음과 같이 정의.

  만약 application.properties 파일에서 값을 가져올 수 없다면 9번 줄에 써놓은 기본값인 'secretKey'를 가져옴.

springboot.jwt.secret=flature!@#

 

- init() 메서드

@PostConstruct
    protected void init() {
    	LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
        
        Logger.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
    }

 - @PostConstruct 어노테이션은 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드를 가리킴.

   JwtTokenProvider 클래스에는 @Component 어노테이션이 지정돼 있어 애플리케이션이 가동되면서 빈으로 자동 주입.

   그때 @PostConstruct가 지정돼 있는 init() 메서드가 자동으로 실행됨.

   init()메서드에서는 secretKey를 Base64 형식으로 인코딩. 인코딩 전후의 문자열을 확인하면 아래와 같음.

 

//인코딩 전 원본 문자열
flature!@#

//Base64 인코딩 결과
ZmxhdHVyZSFAIw== 

 

 

- createToken() 메서드

public String createToken(String userUid, List<String> roles) {
    	LOGGER.info("[createToken] 토큰 생성 시작");
        Claims claims = Jwts.claims().setSubject(userUid);
        claims.put("roles", roles);
        Date now = new Date();
        
        String token = Jwts.builder()
        	.setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
            
            LOGGER.info("[createToken] 토큰 생성 완료");
            return token;
    }

 - 3번 줄 : JWT 토큰의 내용에 값을 넣기 위해 Claims 객체를 생성.

      setSubject() 메서드를 통해 sub 속성에 값을 추가하려면 User의 uid 값을 사용. 

- 4번 줄 : 해당 토큰을 사용하는 사용자의 권한을 확인할 수 있는 role 값을 별개로 추가.

- 7 ~ 12번 줄 : Jwts.builder() 를 사용해 토큰을 생성.

 

- getAuthentication() 메서드

 public Authentication getAuthentication(String token) {
    	LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName : {}",
        	userDetails.getUsername());
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

- 이 메서드는 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성하는 역할.

  Authentication을 구현하는 편한 방법은 UsernamePasswordAuthenticationToken을 사용하는 것.

 

  UsernamePasswordAuthenticationToken의 구조

UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받고 있는데 AbstractAuthenticationToken은 Authentication 인터페이스의 구현체.

 

이 토큰 클래스를 사용하려면 초기화를 위한 UserDetails가 필요. 이 객체는 UserDetailsService를 통해 가져오게 됨.

이때 사용되는 Username 값은 아래와 같이 구현

 

- getUsername() 메서드

public String getUsername(String token) {
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
        String info = Jwts.parser().setSigningKey(secretKey)
        				.parseClaimsJws(token).getBody().getSubject();
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, info : {}", info);
        return info;
    }

- Jwts.parser()를 통해 secretKey를 설정하고 클레임을 추출해서 토큰을 생성할 때 넣었던 sub 값을 추출.

 

 

 

- resolveToken() 메서드

public String resolveToken(HttpServletRequest request) {
    	LOGGER.info("[resolveToken] HTTP 헤더에서 Token값 추출");
        return request.getHeader("X-AUTH-TOKEN");
    }

- HttpServletRequest를 파라미터로 받아 헤더값으로 전달 된 'X-AUTH-TOKEN' 값을 가져와 리턴.

  클라이언트가 헤더를 통해애플리케이션 서버로 JWT 토큰 값을 전달해야 정상적인 추출이 가능.\

  헤더의 이름은 임의로 변경할 수 있음.

 

 

 

- validateToken() 메서드.  

    public boolean validateToken(String token) {
    	LOGGER.info("[validateToken] 토큰 유효 체크 시작");
        
        try {
        	Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
        	LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
            return false;
        }

- 이 메서드는 토큰을 전달받아 클레임의 유효기간을 체크하고 boolean 타입의 값을 리턴하는 역할을 한다.

 

13.5.3 JwtAuthenticationFilter 구현

JwtAuthenticationFilter는 JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스.

 

<JwtAuthenticationFilter>

public class JwtAuthenticationFilter extends OncePerRequestFilter {
	private final Logger LOGGER = LoggerFactory.getLoccer(JwtTokenProvider.class);
	private final JwtTokenProvider jwtTokenProvider;
    
    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
    	this.jwtTokenProvider = jwtTokenProvider;
    }
    
    @Override
    protected void doFilterInternal(HttpServletRequest servletRequest,
    	HttpServletResponse servletResponse,
     	FilterChain filterChain) throws ServletException, IOException {
        String token = jwtTokenProvider.resloveToken(servletRequest);
        LOGGER.info("[doFilterInternal] token 값 추출완료. token : {}", token);
        
        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
        if (token != null && jwtTokenProvider.validateToken(token)) {
        	Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        	LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
        }
        filterChain.doFilter(servletRequest, servletRequest);
    }
}

- 10~25번 줄 : OncePerRequestFilter로부터오버라이딩한 doFilterInternal() 메서드가 있음.

- 24번 줄 : doFilter()메서드는 서블릿을 실행하는 메서드. doFilter() 메서드를 기준으로 앞에 작성한 코드는 서블릿이 실행되기 전에 실행되고, 뒤에 작성한 코드는 서블릿이 실행된 후 실행.

 

메서드 내부 로직에 JwtTokenProvider를 통해 servletRequest에서 토큰을 추출하고, 토큰에 대한 유효성 검사.

토큰이 유효하다면 Authentication 객체를 생성해서 SecurityContextHolder에 추가하는 작업을 수행.

 

13.5.4 SecurityConfiguration 구현

스프링 시큐리티와 관련된 설정 진행. 스프링 시큐리티를 설정하는 대표적인 방법은 WebSecurityConfigureAdapter를 상속받는 Configuration 클래스를 구현하는 것.

 

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;
    
    @Autowired
    public SecurityConfiguration(JwtTokenProvider jwtTokenProvider) {
    	this.jwtTokenProvider = jwtTokenProvider;	
    }
    
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.httpBasic().disable()
        
                .csrf().disable()
                
                .sessionManagement()
                .sessionCreationPolicy(
                	SessionCreationPolicy.STATELESS)
                    
                .and()
                .authorizeRequests()
                .antMatchers("/sign-api/sign-in", "/sign-api/sign-up",
                		"/sign-api/exception").permitAll()
                .antMatchers(HttpMethod.GET, "/product/**).permitAll()
                
                .antMatchers("**exception**").permitAll()
                
                .anyRequest().hasRole("ADMIN")
                
                .and()
                .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
                .and()
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                	UsernamePasswordAuthenticationFilter.class);
    }

    @Override
    public void configure(WebSecurity webSecurity) {
        webSecurity.ignoring().antMatchers("/v2/api-docs", "/swagger-resources/**",
        	"/swagger-ui.html", "/webjars/**", "/swagger/**", "/sign-api/exception");
    }
 }

securityConfiguration 클래스의 주요 메서드는 두 가지로, WebSecurity 파라미터를 받은 configure() 메서드와 HttpSecurity 를 설정하는 configuure() 메서드. 스프링 시큐리티의 설정은 대부분 HttpSecurity를 통해 진행.

대표적인 기능

- 리소스 접근 권한 설정

- 인증 실패 시 발생하는 예외 처리

- 인증 로직 커스터마이징

- csrf, cors등의 스프링 시큐리티 설정

 

configure() 메서드에 작성돼있는 코드 설정 별 설정. 모든 설정은 전달받은 httpSecurity에 설정하게 됨.

- httpBasic().disable()

  UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화

 

- csrf().disable()

  REST API에서는 CSRF 보안이 필요 없기 때문에 비활성화 하는 로직.

  CSRF는 Cross-Site Request ForGery의 줄임말로 '사이트 간 요청 위조' 의미. 

  '사이트 간 요청 위조' 란 웹 애플리케이션의 취약점 중 하나로서 사용자가 자신의 의지와 무관하게 웹 애플리케이션을 대상으로 공격자가 의도한 행동을 함으로써 특정 페이지의 보안을 취약하게 한다거나 수정, 삭제 등의 작업을 하는 공격방법.

  스프링 시큐리티의 csrf() 메서드는 기본적으로 CSRF 토큰을 발급해서 클라이언트로부터 요청을 받을 때마다 토큰을 검증하는 방식으로 동작.

  브라우저 사용 환경이 아니라면 비활성화해도 크게 문제가 되지 않음.

 

- sessionManagement().sessionCreationPolicy(sessionCreationPolicy.STATELESS)

  REST API 기반 애플리케이션의 동작방식을 설정. 지금 진행 중인 프로젝트에서는 JWT 토큰으로 인증을 처리, 

  세션은 사용하지 않기 때문에 STATELESS 설정

 

- authorizeRequest()

  애플리케이션에 들어오는 요청에 대한 사용 권한 체크. 이어서 사용한 antMatchers() 메서는 antPattern 를 통해 권한을 설정하는 역할. 23~29번 줄에 설정된 내용은 다음과 같은 설정 수행.

 

    "/sign-api/sign-in", "/sign-api/sign-up", "/sign-api/exception" 경로에 대해서는 모두에게 허용

    "/product 로 시작하는 경로의 GET 요청은 모두 허용.

    exception 단어가 들어간 경로는 모두 허용.

    기타 요청은 인증된 권한을 가진 사용자에게 허용.

 

- exceptionHandling().accessDeniedHandler()

  권한을 확인하는 과정에서 통과하지 못하는 예외가 발생한 경우 예외를 전달.

 

- exceptionHandling().authenticationEntryPoint()

  인증과정에서 예외가 발생할 경우 예외를 전달.

 

각 메서드는 CustomAccessDeniedHandler와 CustomAuthenticationEntryPoint로 예외 전달.

 

스프링 시큐리티는 각각의 역할을 수행하는 필터들이 체인 형태로 구성돼 순서대로 동작. 

실습을 통해 JWT로 인증하는 필터를 생성했으며, 이 필터의 등록은 HttpSecurity설정에서 진행. 

37~38 줄의 addFilterBefore() 메서드를 사용해 어느 필터 앞에 추가할 것인지 설정 가능. 현재 구현돼 있는 설정은 스프링 시큐리티에서 인증을 처리하는 필터인 UsernamePasswordAuthenticationFilter 앞에 앞에서 생성한 JwtAuthenticationFilter를 추가하겠다는 의미. 추가된 필터에서 인증이 정상적으로 처리되면 UsernamePasswordAuthenticationFilter는 자동으로 통과되기 때문에 위와 같은 구성 선택.

 

- 42~45번 줄 WebSecurity를 사용하는 configure() 메서드 : WebSecurity는 HttpSecurity 앞단에 적용되며, 전체적으로 스프링 시큐리티의 영향권 밖에 있음. 즉 인증과 인가가 모두 적용되기 전에 동작하는 설정.

그렇기 때문에 다양한 곳에서 사용되지 않고 인증과 인가가 적용되지 않는 리소스 접근에 대해서만 사용.

예제에서는 Swagger에 적용되는 인증과 인가를 피하기 위해 ignoring() 메서드를 사용해 Swagger와 관련된 경로에 대한 예외처리를 수행함. 의미상 예외처리라고 표현했지만 정확하게는 인증, 인가는 무시하는 경로를 설정한 것임.

 

13.5.5 커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

인증과 인가 과정의 예외 상황에서 CustomAccessDeniedHandler와 CunstomAuthenticationEntryPoint로 예외를 전달하고 있었음. 

 

<CustomAccessDeniedHandler>

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
	private fianl Logger LOGGER = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
    
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
    	AccessDeniedException exception) throws IOException {
        LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
        response.sendRedirect("/sign-api/exception");
    }
}

- AccessDeniedException은 액세스 권한이 없는 리소스에 접근할 경우 발생하는 예외.

  이 예외를 처리하기 위해 AccessDeniedHandler 인터페이스가 사용되며, SecurityConfiguration에도 exceptionHandling() 메서드를 통해 추가. AccessDeniedHandler의 구현 클래스인 CustomAccessDeniedHandler 클래스는 handle() 메서드를 오버라이딩. 이 메서드는 HttpServletRequest와 HttpServletResponse, AccessDeniedException을 파라미터로 가져옴.

 

 

13.5.6 회원가입과 로그인 구현

User 객체를 생성하기 위해 회원가입을 구현하고 User 객체로 인증을 시도하는 로그인 구현.

회원가입과 로그인의 도메인은 Sign으로 통합해서 표현. 각각 Sign-up, Sign-in으로 구분해서 기능 구현.

 

<SignService>

public interface SignService {
	SignUpResultDto signUp(String id, String password, String name, String role);
    
    SignInResultDto signIn(String id, String password) throws RuntimeException;
}

 

<SignServiceImpl>

@Service
public class SignServiceImpl implements SignService {
	private final Logger LOGGER = LoggerFactory.getLogger(SignServiceImpl.class);
    
    public UserRepository userRepository;
    public JwtTokenProvider jwtTokenProvider;
    public PasswordEncoder passwordEncoder;
    
    @Autowired
    public SignServiceImpl(UserRepository userRepository, JwtTokenProvider jwtTokenProvider,
    	PasswordEncoder passwordEncoder) {
     	this.userRepository = userRepository;
        this.jwtTokenProvider = jwtTokenProvider;
        this.passwordEncoder = passwordEncoder;
    }
    
    @Override
    public SignUpResultDto signUp(String id, String Password, String name, String role) {
    	LOGGER.info("[getSignUpResult] 회원가입 정보 전달");
        User user;
        
        if (role.equalsIgnoreCase("admin") {
        	user = User.builder()
            	.uid(id)
                .name(name)
                .password(passwordEncoder.encode(password)
                .roles(Collections.singletonList("ROLE_ADMIN")
                .build();
        }else {
        	user = User.builder()
            	.uid(id)
                .name(name)
                .password(passwordEncoder.encode(password)
                .roles(Collections.singletonList("ROLE_USER")
                .build();
        }
        
        User savedUser = userRepository.save(user);
        SignUpResultDto signUpResultDto = new SignUpResultDto();
        
        LOGGER.info("[getSignUpResult] userEntity 값이 들어왔는지 확인 후 결과 값 주입");
        if (!savedUser.getName().isEmpty()) {
        	LOGGER.info("[getSignUpResult] 정상 처리 완료");
            setSuccessResult(sighUpResultDto);
        } else {
        	LOGGER.info("[getSignUpResult] 정상 처리 완료");
            setFailResult(sighUpResultDto);
        }
        return sighUpResultDto;
    }
    
    @Override
    public SignInResultDto signIn(String id, Stirng password) throws RuntimeException {
    	LOGGER.info("[getSignInResult] signDataHandler 로 회원 정보 요청");
        User user = userRepository.getByUid(id);
    	LOGGER.info("[getSignInResult] ID : {}", id);
        
    	LOGGER.info("[getSignInResult] 패스워드 비교 수행");
        if (!passwordEncoder.matches(password, user.getPassword()) {
        	throws new RuntimeException();
        }
        LOGGER.info("[getSignInResult] 패스워드 일치");
		
        LOGGER.info("[getSignInResult] SignInResultDto 객체 생성");
		SignInResultDto sighInResultDto = SignInResultDto.builder()
        	.token(jwtTokenProvider.createToken(String.valueOf(user.getUid()),
            	user.getRoles()))
            .build();
        
        LOGGER.info("[getSignInResult] SignInResultDto 객체에 값 주입");
        setSuccessResult(sighInResultDto);
        
        return sighInResultDto;
	}
    
    private void setSuccessResult(SignUpResultDto result) {
    	result.setSuccess(true);
        result.setCode(CommonResponse.SUCCESS.getCode());
        result.setMsg(CommonResponse.SUCCESS.getMsg());
    }
    
    private void setFailResult(SignUpResultDto result) {
    	result.setSuccess(false);
        result.setCode(CommonResponse.FAIL.getCode());
        result.setMsg(CommonResponse.FAIL.getMsg());
    }
}

- 6 ~ 16번쨰 줄에서는 회원가입과 로그인을 구현하기 위해 세 가지 객체에 대한 의존성 주입을 받음.

- 18 ~ 36번째 줄에서는 회원가입을 구현. 현재 애플리케이션에서는 ADMIN과 USER로 권한을 구분하고 있음.

  signUp() 메서드는 그에 맞게 전달받은 role 객체를 확인해 User 엔티티의 roles 변수에 추가해서 엔티티를 생성. 패스워드는 암호화해서 저장해야하기 때문에 PasswordEncoder를 활용해 인코딩을 수행.

 

passwordEncoder는 별도의 @Configuration클래스를 생성하고 @Bean 객체로 등록하도록 구현.

@Configuration
public class PasswordEncoderConfiguration {
	@Bean
    public PasswordEncoder passwordEncoder() {
    	return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

 

13.5.7 스프링 시큐리티 테스트

클라이언트의 입장이 되어 스프링 시큐리티가 동작하는 상황에서 테스트를 수행.

Swagger 활용. Swagger 페이지를 접속하는 경로는 WebSecurity를 사용하는 configure() 메서드에서 인증에 대한 예외 처리를 했기 때문에 정상접속 가능.

 


13.6 정리

스프링 시큐리티는 매우 다양한 방법으로 구현할 수 있게 설계 되어있음.

로그인 폼을 사용해 로그인과 회원가입 기능을 개발할 수도 있으며, OAuth나 소셜 로그인을 연동해서 구현할 수도 있음.