[Spring] Spring Security + JWT 로그인

목포·2021년 9월 14일
34

Spring

목록 보기
1/1

 Spring Security는 강력한 사용자 인증 및 Access 제어 framework이다. 이는 Java 애플리케이션에 인증 및 권한 부여를 제공하는데 중점을 두었으며 다양한 필터를 사용하여 커스터마이징이 가능하다.

용어정리

'인증(Authentication)은 주체(Principal)의 신원(Identity)를 증명하는 과정입니다.'라는 주장을 검증하는 과정이다.
여기서 주체는 보통 유저(사용자)를 가리키며 주체는 자신을 인증해달라고 신원 증명 정보, 즉 Credential을 제시한다. 주체가 유저일 경우 크레덴셜은 대개 패스워드이다.
인가(Authorization, 권한 부여)는 인증을 마친 유저에게 권한 Authority을 부여하여 대상 애플리케이션의 특정 리소스에 접근할 수 있게 허가하는 과정이다. 인가는 반드시 인증 과정 이후 수행돼야하며 권한은 롤 형태로 부여하는 것이 일반적이다.
접근통제(Access Control, 접근 제어)는 애플리케이션 리소스에 접근하는 행위를 제어하는 일이다. 따라서 어떤 유저가 어떤 리소스에 접근하도록 허락할지를 결정하는 행위, 즉 접근 통제 결정이 뒤따른다. 리소스의 접근 속성과 유저에게 부여된 권한 또는 다른 속성들을 견주어 결정한다.
(출처 : Spring 5 레시피)


FilterChain

Spring Security는 표준 서블릿 필터를 사용한다. 다른 요청들과 마찬가지로 HttpServletRequest와 HttpServletResponse를 사용한다. Spring Security는 서비스 설정에 따라 필터를 내부적으로 구성한다. 각 필터는 각자 역할이 있고 필터 사이의 종속성이 있으므로 순서가 중요하다. XML Tag를 이용한 네임스페이스 구성을 사용하는 경우 필터가 자동으로 구성되지만, 네임스페이스 구성이 지원하지않는 기능을 써야하거나 커스터마이징된 필터를 사용해야 할 경우 명시적으로 빈을 등록 할 수 있다.

클라이언트가 요청을 하면 DelegatingFilterProxy가 요청을 가로채고 Spring Security의 빈으로 전달한다. (tmi. DispatcherServlet보다 먼저 실행 됨) 이 DeletgatingFilterProxy는 web.xml과 applicationContext 사이의 링크를 제공한다. 그러니까 DelegatingFilterProxy는 한 마디로 Spring의 애플리케이션 컨텍스트에서 얻은 Filter Bean을 대신 실행한다.
그러니 이 Bean은 javax.servlet.Filter를 구현해야한다. 이 포스팅에서는 jwtAuthenticationFilter가 되겠다. (밑에 소스 코드 참조)

Spring Security Filter Chain는 아래와 같이 다양하며 커스마이징이 가능하다.

  • SecurityContextPersistentFilter : SecurityContextRepository에서 SecurityContext를 가져와서 SecurityContextHolder에 주입하거나 반대로 저장하는 역할을 합니다.
  • LogoutFilter : logout 요청을 감시하며, 요청시 인증 주체(Principal)를 로그아웃 시킵니다.
  • UsernamePasswordAuthenticationFilter : login 요청을 감시하며, 인증 과정을 진행합니다.
  • DefaultLoginPageGenerationFilter : 사용자가 별도의 로그인 페이지를 구현하지 않은 경우, 스프링에서 기본적으로 설정한 로그인 페이지로 넘어가게 합니다.
  • BasicAuthenticationFilter : HTTP 요청의 (BASIC)인증 헤더를 처리하여 결과를 SecurityContextHolder에 저장합니다.
  • RememberMeAuthenticationFilter : SecurityContext에 인증(Authentication) 객체가 있는지 확인하고 RememberMeServices를 구현한 객체 요청이 있을 경우, RememberMe를 인증 토큰으로 컨텍스트에 주입합니다.
  • AnonymousAuthenticationFilter : 이 필터가 호출되는 시점까지 사용자 정보가 인증되지 않았다면 익명 사용자로 취급합니다.
  • SessionManagementFilter : 요청이 시작된 이후 인증된 사용자인지 확인하고, 인증된 사용자일 경우 SessionAuthenticationStrategy를 호출하여 세션 고정 보호 매커니즘을 활성화 하거나 여러 동시 로그인을 확인하는 것과 같은 세션 관련 활동을 수행합니다.
  • ExceptionTranslationFilter : 필터체인 내에서 발생되는 모든 예외를 처리합니다.
  • FilterSecurityInterceptor : AccessDecisionManager로 권한부여처리를 위임하고 HTTP 리소스의 보안 처리를 수행합니다.
    (출처 : https://webfirewood.tistory.com/m/115?category=694472)

스프링 시큐리티의 구조


구글링을 해봤는데 이게 가장 알기쉽게 정리 되어있어서 가져왔다. 이 그림의 프로세스를 하나씩 짚어보자면

  1. 사용자가 입력한 사용자 정보를 가지고 인증을 요청한다.(Request)
  2. AuthenticationFilter가 이를 가로채 UsernamePasswordAuthenticationToken(인증용 객체)를 생성한다
  3. 필터는 요청을 처리하고 AuthenticationManager의 구현체 ProviderManager에 Authentication과 UsernamePasswordAuthenticationToken을 전달한다.
  4. AuthenticationManager는 검증을 위해 AuthenticationProvider에게 Authentication과 UsernamePasswordAuthenticationToken을 전달한다.
  5. 이제 DB에 담긴 사용자 인증정보와 비교하기 위해 UserDetailsService에 사용자 정보를 넘겨주낟.
  6. DB에서 찾은 사용자 정보인 UserDetails 객체를 만든다.
  7. AuthenticationProvider는 UserDetails를 넘겨받고 비교한다.
  8. 인증이 완료되면 권한과 사용자 정보를 담은 Authentication 객체가 반환된다.
  9. AuthenticationFilter까지 Authentication정보를 전달한다.
  10. Authentication을 SecurityContext에 저장한다.

Authentication정보는 결국 SecurityContextHolder 세션 영역에 있는 SecurityContext에 Authentication 객체를 저장한다. 세션에 사용자 정보를 저장한다는 것은 전통적인 세션-쿠키 기반의 인증 방식을 사용한다는 것을 의미한다.

JWT(Json Web Token)

JWT(Json Web Token)은 웹표준(RFC7519)로서 일반적으로 클라이언트와 서버, 서비스와 서비스 사이 통신 시 권한 인가(Authorization)을 위해 사용하는 토큰이다.

HEADER.PAYLOAD.SIGNATURE

일반적으로 헤더, 페이로드, 서명 세 부분을 점으로 구분하는 구조로 되어있다.
헤더에는 토큰 타입과 해싱 알고리즘을 저장하고
페이로드에는 실제로 전달하는 정보, 서명에는 위변조를 방지하기위한 값이 들어가게된다.

사용자 인증이 완료될 시 서버측에서는 JWT 토큰을 Body에 담아오게되고 그 후 요청하는 API 서버에 JWT 토큰을 헤더에 담아 요청을 하게 되면 이를 확인하고 권한이 있는 사용자에게 리소스를 제공하게 된다.

예제 작성하기

개발환경

Intellij IDEA 2019.3.3
SpringFramework 2.3.1.RELEASE
gradle
JDK 1.8

build.gradle

dependencies {
    .
    .
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'
    .
    .
    .
}

일단 필요한 라이브러리들을 받아주고, domain 객체부터 작성한다.

User.java

@Builder
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name="USERS")
public class User{

    @Id
    @Column(name = "user_id")
    private String userId;

    @Column(name = "user_name")
    private String userName;

    @Column(name = "user_pwd")
    private String userPwd;

    @Column
    private String company;

    @Column
    private String position;
}

그 후, DB 접근 시 사용할 Repository와 Service를 만들어줬다. (Service의 경우 나중에 만들어도 됨)

UserRepository.java

@Repository
public interface UserRepository extends JpaRepository<User, String> {

}

UserService.java

UserService의 경우 인증용 도메인과 일반 API 용을 따로 만든다고 하던데.. 난 그냥 쓰던거 사용하고 싶어서 함수 하나만 추가해줬다.

@Service
public class UserService {

    @Autowired
    UserRepository userRepository;

    .
    .
    .
    
    public Optional<User> findByIdPw(String id) { return userRepository.findById(id); }

    .
    .
}

그 후 Spring Security Filter Chain을 사용한다는 것을 명시해줘야 한다. WebSecurityConfigurerAdapter 상속받은 Configuration 객체를 일단 만들어주고 거기에 @EnableWebSecurity 어노테이션을 달아주면 된다.

SecurityConfiguration.java

@Slf4j
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private final JwtAuthenticationEntryPoint unauthorizedHandler;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .cors() //(1)
            .and()
            .csrf() //(2)
            .disable()
            .exceptionHandling() //(3)
            .authenticationEntryPoint(unauthorizedHandler)
            .and()
            .sessionManagement() //(4)
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests() // (5)
            .antMatchers(RestControllerBase.API_URI_PREFIX + "/auth/**")
            .permitAll()
            .antMatchers(RestControllerBase.API_URI_PREFIX + "/**")
            .authenticated()
            .and()
            .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint).and()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
            .formLogin().disable().headers().frameOptions().disable();
            
        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
    }
    
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.addAllowedOrigin("*");
        configuration.setAllowedMethods(Arrays.asList("HEAD", "GET", "POST", "PUT", "DELETE"));
        configuration.addAllowedHeader("*");
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
    
    //비밀번호 암호화를 위한 Encoder 설정
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
 }

(1) 교차출처 리소스 공유(CORS) 설정이다. 나는 현재 vue.js를 클라이언트로 사용하고 있어 서로 다른 포트를 사용하고 있다.(8089, 8081) 그래서 corsConfigurationSource Bean을 통해 addAllowedOrigin (Access-Control-Allow-Origin)에 모든 출처를 허용, setAllowedMethods에 위와 같은 Request 방식을 허용, addAllowedHeader (Access-Control-Allow-Methods)를 허용하도록 설정해줬다.

(2) CSRF(Cross Site Request Forgery) 사이트 간 요청 위조 설정이다. 이건 설정이 복잡하기도하고 REST API 서버용으로만 사용할거기 때문에 disable 해줬다.

(3) 인증, 허가 에러 시 공통적으로 처리해주는 부분이다.

JwtAuthenticationEntryPoint.java

@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(
        HttpServletRequest request,
        HttpServletResponse response,
        AuthenticationException e) throws IOException {

        log.error("Responding with unauthorized error. Message - {}", e.getMessage());

        ErrorCode unAuthorizationCode = (ErrorCode) request.getAttribute("unauthorization.code");

        request.setAttribute("response.failure.code", unAuthorizationCode.name());
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, unAuthorizationCode.message());
    }
}

(4) JWT를 쓰려면 Spring Security에서 기본적으로 지원하는 Session 설정을 해제해야 한다.

(5) 요청에 따른 권한 체크 설정 부분이다. /auth/** 경로로 들어오는 부분은 인증이 필요없고, 그 외 모든 요청들은 인증을 거치도록 설정해줬다.

++ 참고로 Spring Security에서 기본적으로 제공하는 로그인 화면을 사용할거라면 http.httpBasic() 설정을 해줘야한다. 그러면 실행 시 화면이 이렇게 나옴.


이 다음은 JWT 토큰을 통한 인증을 설정해줄 차례이다.

JwtTokenProvider.java

@Slf4j
public class JwtTokenProvider {
    private static final String JWT_SECRET = "secretKey";

    // 토큰 유효시간
    private static final int JWT_EXPIRATION_MS = 604800000;

    // jwt 토큰 생성
    public static String generateToken(Authentication authentication) {

        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + JWT_EXPIRATION_MS);

        return Jwts.builder()
            .setSubject((String) authentication.getPrincipal()) // 사용자
            .setIssuedAt(new Date()) // 현재 시간 기반으로 생성
            .setExpiration(expiryDate) // 만료 시간 세팅
            .signWith(SignatureAlgorithm.HS512, JWT_SECRET) // 사용할 암호화 알고리즘, signature에 들어갈 secret 값 세팅
            .compact();
    }

    // Jwt 토큰에서 아이디 추출
    public static String getUserIdFromJWT(String token) {
        Claims claims = Jwts.parser()
            .setSigningKey(JWT_SECRET)
            .parseClaimsJws(token)
            .getBody();

        return claims.getSubject();
    }

    // Jwt 토큰 유효성 검사
    public static boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(JWT_SECRET).parseClaimsJws(token);
            return true;
        } catch (SignatureException ex) {
            log.error("Invalid JWT signature");
        } catch (MalformedJwtException ex) {
            log.error("Invalid JWT token");
        } catch (ExpiredJwtException ex) {
            log.error("Expired JWT token");
        } catch (UnsupportedJwtException ex) {
            log.error("Unsupported JWT token");
        } catch (IllegalArgumentException ex) {
            log.error("JWT claims string is empty.");
        }
        return false;
    }


}

이제 Jwt 인증 Filter를 만들어준다.

JwtAuthenticationFilter.java

@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
        try {
            String jwt = getJwtFromRequest(request); //request에서 jwt 토큰을 꺼낸다.
            if (StringUtils.isNotEmpty(jwt) && JwtTokenProvider.validateToken(jwt)) {
                String userId = JwtTokenProvider.getUserIdFromJWT(jwt); //jwt에서 사용자 id를 꺼낸다.

                UserAuthentication authentication = new UserAuthentication(userId, null, null); //id를 인증한다.
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); //기본적으로 제공한 details 세팅

                SecurityContextHolder.getContext().setAuthentication(authentication); //세션에서 계속 사용하기 위해 securityContext에 Authentication 등록
            } else {
                if (StringUtils.isEmpty(jwt)) {
                    request.setAttribute("unauthorization", "401 인증키 없음.");
                }

                if (JwtTokenProvider.validateToken(jwt)) {
                    request.setAttribute("unauthorization", "401-001 인증키 만료.");
                }
            }
        } catch (Exception ex) {
            logger.error("Could not set user authentication in security context", ex);
        }

        filterChain.doFilter(request, response);
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.isNotEmpty(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring("Bearer ".length());
        }
        return null;
    }
}

UserAuthentication.java

public class UserAuthentication extends UsernamePasswordAuthenticationToken {

    public UserAuthentication(String principal, String credentials) {
        super(principal, credentials);
    }

    public UserAuthentication(String principal, String credentials,
        List<GrantedAuthority> authorities) {
        super(principal, credentials, authorities);
    }
}

이제 이렇게 만든 Filter를 사용할 수 있도록 SecurityConfiguration에 등록해준다.

SecurityConfiguration.java

.
.
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .cors()
            .and()
            .csrf()
            .disable()
            .exceptionHandling()
            .authenticationEntryPoint(unauthorizedHandler)
            .and()
            .sessionManagement()
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
            .authorizeRequests()
            .antMatchers(RestControllerBase.API_URI_PREFIX + "/auth/**")
            .permitAll()
            .antMatchers(RestControllerBase.API_URI_PREFIX + "/**")
            .authenticated();
    }
.
.
    

이제 막바지다. 로그인 시 인증을 하도록 만들어주면 된다. 일단 입력한 아이디와 비밀번호를 담는 Request 도메인, jwt 토큰 값을 받는 Response 도메인을 만들어준다.

Token.java

public class Token {
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    public static final class Request {
        private String id;
        private String secret;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static final class Response {
        private String token;
    }
}

그리고 Controller 작업.

AuthRestController.java

@Slf4j
@RestController
@RequestMapping(AuthRestController.URL_PREFIX)
public class AuthRestController extends RestControllerBase {
    static final String URL_PREFIX = API_URI_PREFIX + "/auth";
    static final String LOGIN = "/login";

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private UserService userService;

    @RequestMapping(
        value = LOGIN,
        method = RequestMethod.POST,
        produces = MediaType.APPLICATION_JSON_VALUE
    )
    public ResponseEntity<?> login(
        final HttpServletRequest req,
        final HttpServletResponse res,
        @Valid @RequestBody Token.Request request) throws Exception {

        User user = userService.findByIdPw(request.getId()).orElseThrow(() -> new IllegalArgumentException("없는 사용자입니다."));

        if(!request.getSecret().equals(user.getUserPwd())){
            throw new IllegalArgumentException("비밀번호를 확인하세요.");
        }

        Authentication authentication = new UserAuthentication(request.getId(), null, null);
        String token = JwtTokenProvider.generateToken(authentication);

        Response response = Response.builder().token(token).build();

        return okResponse(response);
    }

}

API_URI_PREFIX 같은 건 path에 대한 설정이니 무시하고 본인이 원한대로 진행하면 된다. 여기서 볼 건 DB에 저장된 사용자 인증값과 request를 통해 넘어온 사용자 인증 값을 비교해주고 같다면 인증을 진행하는 부분이다.

이제 Postman같은 API 테스트 플랫폼에서 테스트하면 결과값을 확인할 수 있다.

profile
mokpo devlog

0개의 댓글