본문 바로가기
Develop/Spring

Spring Security + JWT 적용 (로그인 인증, 자격 증명, 검증 구현)

by jaeyoungb 2022. 11. 25.

JWT 적용을 위한 사전 작업

1. 의존 라이브러리 추가

    // Spring Security 적용
    implementation 'org.springframework.boot:spring-boot-starter-security'

    // JWT 기능을 위한 jjwt 라이브러리
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly	'io.jsonwebtoken:jjwt-jackson:0.11.5'

 

2. SecurityConfiguration 추가

  • H2 웹 콘솔을 정상적으로 사용 .frameOptions().sameOrigin() 
  • CSRF(Cross-Site Request Forgery) 공격에 대한 Spring Security에 대한 설정을 비활성화 csrf().disable()
  • CORS 설정 추가 .cors(withDefaults())
    - CORS에 대해
    - https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
    - https://en.wikipedia.org/wiki/Cross-origin_resource_sharing
  • JSON 포맷으로 Username과 Password를 전송하는 방식 사용 .formLogin().disable()
  • HTTP Basic 인증 방식을 비활성화 - Security Filter 비활성화 .httpBasic().disable()
  • 우선 모든 HTTP request 요청에 대해 접근 허용, 추후 변경 예정 .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll())
  • PasswordEncoder Bean 객체 생성
  • 구체적인 CORS 정책을 위한 CorsConfigurationSource Bean 생성
  • - 모든 Origin에 대한 스크립트 기반의 HTTP 통신을 허용 setAllowedOrigins()
  • - 파라미터로 지정한 HTTP Method에 대한 HTTP 통신을 허용 setAllowedMethods()
  • - CorsConfigurationSource 인터페이스의 구현 클래스인 UrlBasedCorsConfigurationSource 클래스 객체 생성
  • - 모든 URL에 앞서 구성한 CORS 정책(CorsConfiguration) 적용 registerConfiguration()

 

3. 회원 가입 로직 수정

- MemberDto.Post 클래스에 패스워드 필드 추가

    // 패스워드 필드 추가
    @NotBlank
    private String password;

* 실제 서비스에서는 회원 가입 시, 사용자가 입력한 패스워드가 맞는지 재확인하기 위해, 패스워드 입력 확인 필드가 추가로 존재하는 경우가 많다.

* 입력한 두 패스워드가 일치하는지를 검증하는 로직도 필요하다.

* 패스워드의 생성 규칙(대/소문자, 패스워드 길이, 특수 문자 포함 여부 등)에 대한 유효성 검증도 실시한다.

 

- Member Entity 클래스에 패스워드 필드 추가

    // 패스워드 필드 추가
    @Column(length = 100, nullable = false)
    private String password;

    // @ElementCollection 애너테이션으로 사용자 등록 시, 사용자의 권한을 등록하기 위한 권한 테이블 생성
    @ElementCollection(fetch = FetchType.EAGER)
    private List<String> roles = new ArrayList<>();

 

- 사용자 등록 시, 패스워드와 사용자 권한 저장

  • PasswordEncoder와 CustomAuthorityUtils 클래스를 DI 받도록 필드 추가
  • 패스워드 단방향 암호화 passwordEncoder.encode()
  • 등록하는 사용자의 권한 정보 생성 authorityUtils.createRoles(),
    publisher.publishEvent(new MemberRegistrationApplicationEvent())

** Spring Security 기반의 애플리케이션에 JWT를 적용하기 위해서는 jjwt나 Java JWT 같은 별도의 라이브러리가 필요

 

 

JWT 자격 증명을 위한 로그인 인증 구현

- 로그인 인증에 성공한 사용자에게 JWT를 생성 및 발급하는 과정

  1. 클라이언트가 서버 측에 로그인 인증 요청(Username/Password를 서버 측에 전송)
  2. 로그인 인증을 담당하는 Security Filter(JwtAuthenticationFilter)가 클라이언트의 로그인 인증 정보 수신
  3. Security Filter가 수신한 로그인 인증 정보를 AuthenticationManager에게 전달해 인증 처리를 위임
  4. AuthenticationManager가 Custom UserDetailsService(MemberDetailsService)에게 사용자의 UserDetails 조회를 위임
  5. Custom UserDetailsService(MemberDetailsService)가 사용자의 크리덴셜을 DB에서 조회한 후, AuthenticationManager에게 사용자의 UserDetails를 전달
  6. AuthenticationManager가 로그인 인증 정보와 UserDetails의 정보를 비교해 인증 처리
  7. JWT 생성 후, 클라이언트의 응답으로 전달

 

1. UserDetailsService를 구현한 MemberDetailsService 클래스 구현

- 데이터베이스에서 사용자의 크리덴셜을 조회한 후, 그 크리덴셜을 AuthenticationManager에게 전달하는 Custom UserDetailsService 구현

 

2. 로그인 인증 정보 역직렬화(Deserialization)를 위한 LoginDto 클래스 생성

    @Getter
    public class LoginDto {
        private String username;
        private String password;
    }

 

3. JWT를 생성하는 JwtTokenizer 구현

- 로그인 인증에 성공한 클라이언트에게 JWT를 생성 및 발급하고 클라이언트의 요청이 들어올 때마다 전달된 JWT를 검증하는 역할

  • JwtTokenizer 클래스를 Spring Container(ApplicationContext)에 Bean으로 등록 @Component
  • JWT 생성 및 검증 시 사용되는 Secret Key 정보 추가 @Value(${...})
  • Access Token에 대한 만료 시간 정보 추가 @Value(${...})
  • Refresh Token에 대한 만료 시간 정보 추가 @Value(${...})
  • JWT의 만료 일시를 지정하고 이는 JWT 생성 시 사용 getTokenExpiration() (Date 타입)

 

4. JwtTokenizer의 정보에 맞춰 application.yml 파일 코드 추가

** JWT 서명에 사용되는 Secret Key 정보는 민감한 정보이므로, 시스템 환경 변수의 변수로 등록

** 시스템 환경 변수에 등록한 변수는 application.yml 파일의 프로퍼티명과 동일한 문자열을 사용하지 않도록 주의

설명 예시

** 일반적으로 Access Token의 만료 시간은 Refresh Token보다 짧은 것이 권장되고, 보안 강화의 이유로 Refresh Token을 제공하지 않는 애플리케이션도 존재

 

5. 로그인 인증 요청을 처리하는 Custom Security Filter 구현

- 로그인 인증 정보를 수신하고 인증 처리의 엔트리포인트 역할

  • UsernamePasswordAuthenticationFilter 클래스 상속
  • AuthenticationManager와 JwtTokenizer를 DI 받음
  • 인증을 시도하는 메서드 구현 attempAuthentication()
  • - Username/Password를 DTO 클래스로 역직렬화하기 위해 ObjectMapper 인스턴스 생성
  • - 역직렬화 objectMapper.readValue(request.getInputStream(), LoginDto.class)
  • - Username/Password를 포함한 UsernamePasswordAuthenticationToken 생성
  • - UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달하며 인증 처리 위임 authenticate()
  • 인증 정보를 이용해 인증에 성공할 경우 호출되는 메서드 구현 successfulAuthentication()
  • - Member Entity 클래스의 객체를 얻음 authResult.getPrincipal()
  • - Access Token 생성 delegateAccessToken(member)
  • - Refresh Token 생성 delegateRefreshToken(member)
  • delegateAccessToken(), delegateRefreshToken() 메서드 구현

 

6. Custom Filter 추가를 위한 SecurityConfiguration 설정 추가

** Spring Security에서는 직접 Configuration을 커스터마이징(customizations)할 수 있다.

  • 커스터마이징된 Configuration을 추가 apply()
  • AbstractHttpConfigurer를 상속해서 Custom Configurer 클래스를 구현
  • Configuration을 커스터마이징 configure() 메서드 오버라이딩
  • SecurityConfigurer간에 공유되는 객체(AuthenticationManager)를 얻음 getSharedObject(Authentication.class)
  • JwtAuthenticationFilter를 생성하면서 JwtAuthenticationFilter에서 사용되는 AuthenticationManager와 JwtTokenizer를 DI 해줌
  • 디폴트 request URL을 변경 setFilterProcessesUrl()
  • Spring Security Filter Chain에 JwtAuthenticaionFilter를 추가 addFilter()

 

로그인 인증 성공 및 실패에 따른 추가 처리

- Spring Security에서는 AuthenticationSuccessHandler(성공 시), AuthenticationFailureHandler(실패 시)를 지원

 

1. AuthenticationSuccessHandler 구현하는 클래스 생성

** Authentication 객체에 사용자의 정보를 얻은 후, HttpServletResponse로 출력 스트림을 생성해서 reponse로 전송할 수도 있다.

 

2. AuthenticationFailureHandler 구현하는 클래스 생성

  • 메서드 구현 및 추가 처리 onAuthenticationFailure()
  • 출력 스트림에 Error 정보를 담는 메서드 구현 sendErrorResponse()
  • - ErrorResponse를 JSON 문자열로 변환하기 위한 Gson 라이브러리의 인스턴스를 생성
  • - ErrorResponse 객체를 생성하고, HttpStatus.UNAUTHORIZED 상태 코드 전달 ErrorResponse.of()
  • - response가 JSON 형태라는 것을 HTTP Header에 추가 setContentType(MediaType.APPLICATION_JSON_VALUE)
  • - response의 상태가 401임을 클라이언트에게 알려주기 위해 HTTP Header에 추가 setStatus(HttpStatus.UNAUTHORIZED.value())
  • Gson을 이용해 ErrorResponse 객체를 JSON 포맷 문자열로 변환 후, 출력 스트림 생성
    getWriter().write(gson.toJson(errorResponse, ErrorResponse.class))

    - AuthenticationFailureHandler에 대해
    - https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/web/authentication/AuthenticationFailureHandler.html

 

3. AuthenticationSuccessHandler와 AuthenticationFailureHandler를 SecurityConfiguration에 추가

- new 키워드를 사용해 AuthenticationSuccessHandler 인터페이스의 구현 클래스와 AuthenticationFailureHandler 인터페이스의 구현 클래스를 추가

** Security Filter마다 두 핸들러의 구현 클래스를 각각 생성할 것이기 때문에, Bean으로 등록해서 DI 받는 것이 아닌, new 키워드를 사용해서 객체를 생성한다.

 

4. JwtAuthenticationFilter 클래스에 AuthenticationSuccessHandler 호출 코드 추가

  • AuthenticationSuccessHandler 클래스의 onAuthenticationSuccess() 메서드 호출

** onAuthenticationSuccess() 메서드를 호출하면, AuthenticationSuccessHandler를 구현한 클래스의 onAuthenticationSuccess() 메서드가 호출된다.

** 별도의 코드를 추가하지 않더라도, 로그인 인증에 실패하면 AuthenticationFailureHandler를 구현한 클래스의 onAuthenticationFailure() 메서드가 알아서 호출된다.

 

자격 증명 성공!
자격 증명 실패!

 

** Username/Password 기반의 로그인 인증은 반드시 UsernamePasswordAuthenticationFilter만 이용해서 구현할 필요는 없으며, OncePerRequestFilter를 구현하거나 Controller에서 API 엔드포인트로 구현하는 방법도 존재한다.

- OncePerRequestFilter에 대해

- https://www.baeldung.com/spring-onceperrequestfilter

- https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/OncePerRequestFilter.html

 

 

JWT 검증 기능 구

- 클라이언트 측에서 JWT를 이용해 자격 증명이 필요한 리소스에 대한 request 전송 시, request header를 통해 전달받은 JWT를 서버 측에서 검증하는 기능 구현

 

1. JWT 검증 필터 구현

- JWT를 검증하는 전용 Security Filter를 구현

  • OncePerRequestFilter를 확장해서 request 당 한 번만 실행되는 Security Filter 구현
    ** 인증과 관련된 Filter는 성공이냐 실패냐를 단 한 번만 판단하면 된다. 즉, 여러 번 판단할 필요가 없다.
  • JWT를 검증하고 Claims(토큰에 포함된 정보)를 얻는 데 사용되는 JwtTokenizer와 JWT 검증에 성공 시, Authentication 객체에 채울 사용자의 권한을 생성하는 데 사용되는 CustomAuthorityUtils를 DI 받음
  • JWT를 검증하는 데 사용되는 메서드 구현 verifyJws()
  • - request의 header에서 JWT를 얻음 getHeader(), "Bearer " 부분 제거 replace()
  • ** 서명된 JWT를 JWS(JSON Web Token Signed)라고 부른다.
  • - JWT 서명(Signature)를 검증하기 위한 Secret Key를 얻음 getSecretKey()
  • - JWT에서 Claims를 파싱 getClaims().getBody()
  • ** Claims가 정상적으로 파싱되면, 서명 검증은 성공한 것이다.
  • Authentication 객체를 SecurityContext에 저장하기 위한 메서드 구현 setAuthenticationToContext()
  • - JWT에서 파싱한 Claims에서 username을 얻음 get()
  • - JW의 Claims에서 얻은 권한 정보로 List<GrantedAuthority>를 생성
  • - username과 List<GrantedAuthority>를 포함한 Authentication 객체 생성
  • - SecurityContext에 Authentication 객체 저장 SecurityContextHolder.getContext().setAuthentication()
  • ** SecurityContext에 Authentication을 저장하게 되면, Spring Security의 세션 정책에 따라 세션을 생성 or 안할 수도 있다.
  • SecurityContext에 Authentication을 저장하고 나서, 다음 Security Filter를 호출 doFilter()
  • 특정 조건에 true면, 해당 Filter를 건너뛰고 다음 Filter를 호출하는 shouldNotFilter()를 오버라이딩
  • - Authorization header의 값을 얻음 getHeader()
  • - Authorization header의 값이 null or "Bearer"로 시작하지 않으면, 해당 Filter를 건너띄도록 정의
    return authorization == null || ! authorization.startsWith("Bearer");
  • ** 실수로 JWT를 포함하지 못한 리소스 요청을 보낸 경우에, Authentication이 정상적으로 SecurityContext에 저장되지 않은 상태이기 때문에, 다른 SecurityFilter를 거쳐 결국에는 Exception을 던짐

 

2. SecurityConfiguration 업데이트

- 세션 정책 설정 추가, JwtVerificationFilter 추가

  • 세션을 생성하지 않도록 설정 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    - Spring Security Session Policy
    - https://www.baeldung.com/spring-security-session
  • JwtVerifcationFilter의 인스턴스를 생성하면서, 생성자로 JwtTokenizer와 CustomAuthorityUtils의 객체를 DI 받음
  • JwtVerificationFilter를 JwtAuthenticationFilter가 수행된 후 바로 다음에 동작하도록 추가 builder.addFilterAfter()
  • ** 인증 과정이 수행된 후에 JwtVerficationFilter가 수행되는 것이 자연스러운 흐름이다.

 

서버 측 리소스에 역할(Role) 기반 권한 적용

- 자격 증명 뿐이 아니라, 특정 리소스에 접근할 권한을 설정해야 함

- .anyRequest().permitAll() 부분을 수정

  • 회원 등록의 경우, 해당 URL과 HTTP Mehtod(POST)이면 접근 허용
    .antMatchers(HttpMethod.POST, "/*/members").permitAll()
  • 회원 정보 수정의 경우, USER 권한만 가진 사용자만 접근 허용
    .antMatchers(HttpMethod.PATCH, "/*/members/**").hasRole("USER")
    **는 하위 URL로 어떤 URL이 와도 매치가 된다는 의미이다.
  • 모든 회원 정보의 목록의 경우, ADMIN 권한을 가진 관리자만 접근 허용
    .antMatchers(HttpMethod.GET, "/*/members").hasRole("ADMIN")
  • 특정 회원에 대한 정보 조회의 경우, USER, ADMIN 권한을 가진 사용자만 접근 허용
    .antMatchers(HttpMethod.GET, "/*/members/**").hasAnyRole("USER", "ADMIN")
  • 특정 회원을 삭제하는 경우, USER 권한을 가진 사용자만 접근 허용
    .antMatchers(HttpMethod.DELETE, "/*/members/**").hasRole("USER")
  • 나머지 리소스의 경우 모든 접근 허용
    .anyRequest().permitAll()

 

예외 처리

1. JwtVerificationFilter에 예외 처리 로직 추가

- JWT에 대한 서명 검증에 실패할 경우, throw되는 SingatureException에 대한 처리 추가

- JWT가 만료될 경우, ExpiredJwtException에 대한 처리 추가

  • try-catch문 이용해서, 특정 예외를 catch하면, HttpServletRequest의 Attribute로 추가
    try {
        Map<String, Object> claims = verifyJws(request);
        setAuthenticationToContext(claims);
    } catch (SignatureException se) {
        request.setAttribute("exception", se);
    } catch (ExpiredJwtException ee) {
        request.setAttribute("exception", ee);
    } catch (Exception e) {
        request.setAttribute("exception", e);
    }

** 예외 처리 로직에서 Exception을 다시 throw하지 않고, Attribute에 추가하는 처리만 한다.

** 이렇게 되면, SecurityContext에 클라이언트의 인증 정보(Authentcation 객체)가 저장되지 않고, 이대로 Security Filter 로직을 수행하게 되면 AuthenticationException이 발생한다.

** 이 AuthenticationException은 AuthenticationEntryPoint가 처리하게 된다.

 

2. AuthenticationEntryPoint 인터페이스를 구현하는 클래스 생성

 

3. AccessDeniedHandler 인터페이스를 구현하는 클래스 생성

- 요청한 리소스에 대해 적절한 권한이 없을 경우 호출되는 핸들러

 

4. SecurityConfiguration 업데이트

- new 키워드를 사용해 AuthenticationEntryPoint 인터페이스의 구현 클래스와 AccessDeniedHandler 인터페이스의 구현 클래스를 추가

 

 

** 추후에 Spring Security를 통해 보안이 적용된 상태에서 Spring Rest Docs를 이용해 API 문서를 업데이트할 것.

 

 

- JWT using java and Spring Security
- https://www.toptal.com/java/rest-security-with-jwt-spring-security-and-java