본문 바로가기

Spring/Security

Spring Security 11: JWT #2

이제 세션 기반 로그인이 아닌 JWT를 이용한 로그인을 코드로 적용해보자

 

 

1. 먼저 jwt를 위해 필요한 라이브러리를 추가한다.

<dependency>
  <groupId>com.auth0</groupId>
  <artifactId>java-jwt</artifactId>
  <version>3.18.1</version>
</dependency>

 

2.  다음으로 jwt 토큰을 구성하기 위한 설정 값들을 설정한다.

 

jwt:
	header: "${헤더이름}"
  issuer: "${발급자}"
  client-secret: "${서명을 위한 키}"
  expiry-seconds: "${토큰 만료 시간}"
  
  @Component
@ConfigurationProperties(prefix = "jwt")
public class JwtConfigure {

  private String header;

  private String issuer;

  private String clientSecret;

  private int expirySeconds;

  public String getHeader() {
    return header;
  }

  public void setHeader(String header) {
    this.header = header;
  }

  public String getIssuer() {
    return issuer;
  }

  public void setIssuer(String issuer) {
    this.issuer = issuer;
  }

  public String getClientSecret() {
    return clientSecret;
  }

  public void setClientSecret(String clientSecret) {
    this.clientSecret = clientSecret;
  }

  public int getExpirySeconds() {
    return expirySeconds;
  }

  public void setExpirySeconds(int expirySeconds) {
    this.expirySeconds = expirySeconds;
  }

}

 

3. 토큰을 발급하기 위해 Jwt 클래스를 작성한다. 해당 클래스는 토큰 발급, 토큰 유효 검사 메소드를 수행한다.

 

public class Jwt {

    private final String issuer; // 발행자
    private final String clientSecret; // 클라이언트 비밀 키
    private final int expirySeconds; // 만료 시간 (초)
    private final Algorithm algorithm; // 서명시 적용할 암호화 알고리즘(앞서 작성한 clientSecret과 함게 사용
    private final JWTVerifier jwtVerifier; // JWT 검증 객체

    // 생성자
    public Jwt(String issuer, String clientSecret, int expirySeconds) {
        this.issuer = issuer;
        this.clientSecret = clientSecret;
        this.expirySeconds = expirySeconds;
        this.algorithm = Algorithm.HMAC512(clientSecret); // HMAC512 알고리즘 사용
        this.jwtVerifier = JWT.require(algorithm)
                .withIssuer(issuer)
                .build(); // 검증 객체 초기화
    }

    // JWT 토큰 생성 메소드
    public String sign(Claims claims) {
        Date now = new Date();
        JWTCreator.Builder builder = JWT.create();
        builder.withIssuer(issuer); // 발행자 설정
        builder.withIssuedAt(now); // 발행 시간 설정

        if (expirySeconds > 0) {
            builder.withExpiresAt(new Date(now.getTime() + (expirySeconds * 1_000L))); // 만료 시간 설정
        }
        builder.withClaim("username", claims.username); // 사용자 이름 설정
        builder.withArrayClaim("roles", claims.roles); // 역할 설정

        return builder.sign(algorithm); // 토큰 서명 및 반환
    }

    // JWT 토큰 검증 메소드
    public Claims verify(String token) throws JWTVerificationException {
        return new Claims(jwtVerifier.verify(token)); // 토큰 검증 및 클레임 반환
    }

    // 클레임 클래스
    public static class Claims {
        String username; // 사용자 이름
        String[] roles; // 역할
        Date iat; // 발행 시간
        Date exp; // 만료 시간

        private Claims() {}

        Claims(DecodedJWT decodedJWT) {
            Claim username = decodedJWT.getClaim("username");
            if (!username.isNull()) {
                this.username = username.asString(); // 사용자 이름 추출
            }

            Claim roles = decodedJWT.getClaim("roles");
            if(!roles.isNull()) {
                this.roles = roles.asArray(String.class); // 역할 추출
            }

            this.iat = decodedJWT.getIssuedAt(); // 발행 시간 추출
            this.exp = decodedJWT.getExpiresAt(); // 만료 시간 추출
        }

        // 클레임 생성 메소드
        public static Claims from(String username, String[] roles) {
            Claims claims = new Claims();
            claims.username = username;
            claims.roles = roles;
            return claims;
        }

        // 클레임을 맵으로 변환하는 메소드
        public Map<String, Object> asMap() {
            Map<String, Object> map = new HashMap<>();
            map.put("username", username);
            map.put("roles", roles);
            map.put("iat", iat());
            map.put("exp", exp());
            return map;
        }

        // 발행 시간 반환 메소드
        public long iat() {
            return iat != null ? iat.getTime() : -1;
        }

        // 만료 시간 반환 메소드
        public long exp() {
            return exp != null ? exp.getTime() : -1;
        }
    }
}

 

4. JWT 인증객체 생성

public class JwtAuthentication {//SecurityContextHolder에 담을 인증객체

    public final String token;

    public final String username;


    public JwtAuthentication(String token, String username) {
        checkArgument(isNotEmpty(token), "token must be provided");
        checkArgument(isNotEmpty(username), "username must be provided");

        this.token = token;
        this.username = username;
    }
}

 

5. jwt 토큰 인증 요청시 거칠 필터 작성

 

public class JwtAuthenticationFilter extends GenericFilterBean {

    private final Logger logger = LoggerFactory.getLogger(this.getClass().getName());

    private final String headerKey; // JWT 토큰을 찾을 HTTP 헤더의 키
    private final Jwt jwt; // JWT 처리 객체

    public JwtAuthenticationFilter(String headerKey, Jwt jwt) {
        this.headerKey = headerKey;
        this.jwt = jwt;
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        // HTTP 요청에서 JWT 토큰을 찾고, 토큰을 검증하여 인증 정보를 생성하고 SecurityContextHolder에 저장

        var request = (HttpServletRequest) servletRequest;
        var response = (HttpServletResponse) servletResponse;

        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            try {
                var token = getToken(request);
                Optional.ofNullable(token)
                        .map(this::verify)
                        .filter(claims -> isNotEmpty(claims.username) && !getAuthorities(claims).isEmpty())
                        .ifPresent(claims -> {
                            logger.debug("Jwt parsed: {}", claims);
                            var username = claims.username;
                            var authorities = getAuthorities(claims);
                            var authentication = new JwtAuthenticationToken(new JwtAuthentication(token, username),
                                    null,
                                    authorities
                            );
                            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                            SecurityContextHolder.getContext().setAuthentication(authentication); // 인증 정보 저장
                        });
            } catch (Exception e) {
                logger.error("Failed to verify token", e);
                return;
            }
        } else {
            logger.debug("SecurityContextHolder not populated with security token, as it already contained: '{}'",
                    SecurityContextHolder.getContext().getAuthentication());
        }

        filterChain.doFilter(request, response); // 필터 체인 계속
    }

    private String getToken(HttpServletRequest request) {
        // HTTP 헤더에서 JWT 토큰을 추출
        String token = request.getHeader(headerKey);
        if (isNotEmpty(token)) {
            logger.debug("Found token in header: {}", token);
            try {
                return URLDecoder.decode(token, "UTF-8"); // 토큰 디코딩
            } catch (Exception e) {
                logger.error("Failed to decode token: {}", token, e);
            }
        }

        return null;
    }

    private Jwt.Claims verify(String token) {
        return jwt.verify(token); // 토큰 검증
    }

    private List<? extends GrantedAuthority> getAuthorities(Jwt.Claims claims) {
        // 클레임에서 권한 정보 추출
        return Optional.ofNullable(claims.roles)
                .filter(roles -> roles.length > 0)
                .map(roles -> Arrays.stream(roles).map(SimpleGrantedAuthority::new).toList())
                .orElse(Collections.emptyList());
    }
}

 

 

6.  유효한 사용자인지 검증하긴 위한 UserService 작성

@Service
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    public User login(String username, String credentials) {
        var user = userRepository.findByLoginIdWithAuthorities(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found user for login id: " + username));
        user.checkPassword(passwordEncoder, credentials);

        return user;
    }

    public User findByLoginId(String loginId) {
        return userRepository.findByLoginIdWithAuthorities(loginId)
                .orElseThrow(() -> new UsernameNotFoundException("User not found user for login id: " + loginId));
    }



    /*@Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByLoginId(username)
                .map(user -> User.builder()
                        .username(user.getLoginId())
                        .password(user.getPassword())
                        .authorities(user.getGroup().getAuthorities())
                        .build())
                .orElseThrow(() -> new UsernameNotFoundException("User not found user for login id: " + username));
    }*/
}

-> 이때 User를 조회할 때 fetch 조인을 써서 n+1 문제, LazyInitailiztion 예외 방지

 

7. 인증을 직접적으로 처리해주는 AuthenticationProvider클래스 생성

 

public class JwtAuthenticationProvider implements AuthenticationProvider {

    private final Jwt jwt; // JWT 처리 객체
    private final UserService userService; // 사용자 서비스

    public JwtAuthenticationProvider(Jwt jwt, UserService userService) {
        this.jwt = jwt;
        this.userService = userService;
    }

    @Override
    public boolean supports(Class<?> authentication) {
        // JwtAuthenticationToken 클래스 또는 하위 클래스를 지원하는지 확인
        return (JwtAuthenticationToken.class.isAssignableFrom(authentication));
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        JwtAuthenticationToken token = (JwtAuthenticationToken) authentication;

        // 사용자 인증 처리
        return processUserAuthentication(String.valueOf(token.getPrincipal()), String.valueOf(token.getCredentials()));
    }

    private Authentication processUserAuthentication(String principal, String credentials) {
        try {
            var user = userService.login(principal, credentials); // 사용자 로그인
            var authorities = user.getGroup().getAuthorities(); // 권한 정보 가져오기
            var token = getToken(user.getLoginId(), authorities); // 토큰 생성
            var authenticated = new JwtAuthenticationToken(new JwtAuthentication(token, user.getLoginId()),
                    null,
                    authorities
            );
            authenticated.setDetails(user); // 사용자 정보 설정
            return authenticated; // 인증된 토큰 반환
        } catch (IllegalArgumentException e) {
            throw new BadCredentialsException(e.getMessage()); // 잘못된 자격 증명 예외 처리
        } catch (Exception e) {
            throw new AuthenticationServiceException(e.getMessage()); // 인증 서비스 예외 처리
        }
    }

    private String getToken(String username, List<? extends GrantedAuthority> authorities) {
        // 권한 정보를 기반으로 JWT 토큰 생성
        var roles = authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .toArray(String[]::new);
        return jwt.sign(Jwt.Claims.from(username, roles));
    }
}

 

8. 다음으로 여태 만들어온 jwt 인증 관련 필터, 객체들을 빈에 등록

 

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    private static final Logger log = LoggerFactory.getLogger(WebSecurityConfig.class);

    private final JwtConfigure jwtConfigure;

    public WebSecurityConfig(JwtConfigure jwtConfigure) {
        this.jwtConfigure = jwtConfigure;
    }

    @Bean
    Jwt jwt() {
        return new Jwt(jwtConfigure.getIssuer(),
                jwtConfigure.getClientSecret(),
                jwtConfigure.getExpirySeconds());
    }

    @Bean
    JwtAuthenticationProvider jwtAuthenticationProvider(Jwt jwt, UserService userService) {
        return new JwtAuthenticationProvider(jwt, userService);
    }

    @Bean
    WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring().requestMatchers(antMatcher("/assets/**"), antMatcher("/h2-console/**"));
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter(jwtConfigure.getHeader(), jwt());
    }

    @Bean
    public AuthenticationManager authenticationManager(HttpSecurity http, UserService userService) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder =
                http.getSharedObject(AuthenticationManagerBuilder.class);
        authenticationManagerBuilder.authenticationProvider(jwtAuthenticationProvider(jwt(), userService));
        return authenticationManagerBuilder.build();
    }

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

        /**
         * spring security user 추가
         */

        http
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers(antMatcher("/api/users/m2")).access(new HierarchyBasedAuthorizationManager(roleHierarchy()))
                        .anyRequest().permitAll())
                .csrf(AbstractHttpConfigurer::disable)
                .headers(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .rememberMe(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable)
                .sessionManagement((sessionManagement) -> sessionManagement
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .exceptionHandling((exceptionHandling) -> exceptionHandling
                        .accessDeniedHandler(accessDeniedHandler())
                )
                .addFilterAfter(jwtAuthenticationFilter(), SecurityContextHolderFilter.class);
        return http.build();
    }// 이것도 마찬가지로 변경 됏음. 과거 사용했던 메소드 밑에 현재 버전에 맞는 메소드가 있음.

    @Bean
    AccessDeniedHandler accessDeniedHandler() {
        return (request, response, e) -> {
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            Object principal = authentication != null ? authentication.getPrincipal() : null;
            log.warn("{} is denied", principal, e);
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setContentType("text/plain");
            response.getWriter().write("### Access Denied ###");
            response.getWriter().flush();
            response.getWriter().close();
        };
    }// 접근 거부 핸들러

    @Bean
    public RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");
        return roleHierarchy;
    }/*
       계층형 권한 설정 스프링 시큐리티 버전 6.0에 추가된 내용.
       계층형 권한 설정을 통해 ROLE_ADMIN이 ROLE_USER보다 상위 권한이라는 것을 설정함.
     */


    static class HierarchyBasedAuthorizationManager implements AuthorizationManager<RequestAuthorizationContext> {

        private final RoleHierarchy roleHierarchy;

        public HierarchyBasedAuthorizationManager(RoleHierarchy roleHierarchy) {
            this.roleHierarchy = roleHierarchy;
        }

        @Override
        public AuthorizationDecision check(Supplier<Authentication> authentication, RequestAuthorizationContext object) {
            var userAuthorities = authentication.get().getAuthorities();
            var reachableAuthorities = roleHierarchy.getReachableGrantedAuthorities(userAuthorities);

            var hasUserRole = reachableAuthorities.stream()
                    .anyMatch(auth -> "ROLE_USER".equals(auth.getAuthority()));

            return new AuthorizationDecision(hasUserRole);
        }
    }
}

 

9. 로그인을 위한 컨트롤러 작성

 

@RestController
@RequestMapping("/api")
public class UserController {

    private final UserService userService;

    private final AuthenticationManager authenticationManager;

    public UserController(UserService userService, AuthenticationManager authenticationManager) {
        this.userService = userService;
        this.authenticationManager = authenticationManager;
    }

    @PostMapping("/users/login")
    public UserResponse login(@RequestBody loginRequest request) {
        var authToken = new JwtAuthenticationToken(request.principal(), request.credentials());
        var resultToken = authenticationManager.authenticate(authToken);
        var authenticated = (JwtAuthenticationToken) resultToken;
        var principal = (JwtAuthentication) authenticated.getPrincipal();
        var user = (User) authenticated.getDetails();

        return new UserResponse(principal.token, principal.username, user.getGroup().getName());
    }

    @GetMapping("/users/me")
    public UserResponse me(@AuthenticationPrincipal JwtAuthentication authentication) {
        var user = userService.findByLoginId(authentication.username);
        return new UserResponse(authentication.token, authentication.username, user.getGroup().getName());

    }
}

 

 

이제 jwt를 이용한 로그인이 구현이 끝났다. 다음 시간에는 oauth2 + jwt를 이용한 로그인 구현 방법을 알아보자