본문 바로가기
Spring/security

Spring Security6 JWT구성

by 옹알이옹 2024. 4. 22.
목차

1. security config 코드

2. 로그인 과정 및 코드

3. 로그인 후 인증,인가 처리 과정 및 코드


 

 1. secyrity config  코드

 

Security6 버전으로 올라오면서 구현 코드 문법이 좀 바뀌긴 했지만, 개념적인 부분은 기존과 동일하다.

우선 config 구현 코드는 아래와 같다.

 

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true) // 이걸 추가해야 어노테이션 기반 권한 설정이 동작
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

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

        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(authorize -> authorize
                    .requestMatchers("/login", "/signup/**").permitAll()
                    .requestMatchers("/member/**").hasRole("USER")
                    .requestMatchers("/admin/**").hasRole("ADMIN")
                    .anyRequest().authenticated()
            );

        http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
            .httpBasic(Customizer.withDefaults());

        http
            .exceptionHandling(exceptionHandling -> exceptionHandling
            .accessDeniedHandler(jwtAccessDeniedHandler));

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

 

 

 

코드 설명

 

  • .csrf(AbstractHttpConfigurer::disable) : 현재 rest api 구성하여 서버가 statleless하기 때문에 비활성화시킨다.
  • .sessionManagement(configurer -> configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
    : session 방식이 아닌 jwt 토큰 방식을 사용할 것이기 때문에 statless 상태를 명시해 준다.
  • authorizeHttpRequests : API end point 별로 인증, 인가에 대한 설정
  • httpBasic(Customizer.withDefaults()) : http 기본 인증을 사용한다는 코드로 명시하지 않으면 404,401등의 에러 코드가 모두 403으로 응답받게 된다.
  • addFilterBefore : 새로 적용시킬 jwtFilter를 등록해 준다.
  • exceptionHandling : 403(권한)에 대한 예외를 커스텀하여 응답해 주기 위해 등록해 준다.
  • PasswordEncoder : passwordEncoder의 구현체로 BCryptPasswordEncoder를 생성하고 빈으로 등록한다.
    해당 빈은 로그인 시 authenticationManagerBuilder가 입력 비번과 암호화된 DB의 비번을 를 비교하는 과정에서 사용

 

 2. 로그인 과정 및 코드

 

먼저 로그인 과정을 대략 적으로 설명하면 이렇다.

  1. SecurityConfig에 /login URL에 대한 필터 검증 생략 처리
  2. loginService에서 UsernamePasswordAuthenticationToken에 id와 pwd를 넘겨서 객체 생성
    •  해당 객체를 생성할 때 DB조회를 하지 않으면 권한 정보도 없다.
  3. 생성된 해당 객체를 Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
    •   authenticate를 실행할 때 UserDetailService의 loadUserByUsername을 실행하여 DB에서 유저 정보 조회
    •   반환받은 유저 정보의 암호화된 pwd와 입력받은 pwd를 비교하여 매칭되지 않을 경우     BadCredentialsException   발생 시킴(해당 예외는 conrollerAdviser에 등록하여 잡았음.)
    •   authenticationManagerBuilder를 사용하지 않고 직접 DB조회를 하는 코드를 구현해도 된다.(케바케 같음)
  4. 위의 코드가 문제없이 실행되면  Authentication 객체를 반환받는다.
  5. Authentication 객체에서 이름과 권한 정보, 만료 날짜등을 사용하여 secretKey로 서명한 뒤 토큰 생성
  6. 로그인 완료 된 사용자에게 토큰 반환

구현 코드

 

로그인 Service

public String login(LoginDto loginDto){

    UsernamePasswordAuthenticationToken authenticationToken =
            new UsernamePasswordAuthenticationToken(loginDto.getId(), loginDto.getPassword());

    Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

    String token = jwtTokenProvider.createToken(authentication);

    return token;
}

 

UserDetailService 구현체

@Service
public class MemberDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;

    public MemberDetailService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        System.err.println("============ loadUserByUsername ==============");
        Optional<Member> memberOpt = memberRepository.findById(new MemberId(username));
        Member member = memberOpt.orElseThrow(() -> new MemberNotFountException("존재 하지 않는 회원"));
        return member;
    }
}

 

UserDetails 구현체

@Entity
public class Member implements UserDetails {

    @EmbeddedId
    private MemberId memberId;
    @Embedded
    private Password password;
    private String name;

    @Getter
    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "member_roles", joinColumns = @JoinColumn(name = "member_id"))
    @Enumerated(EnumType.STRING)
    private List<Role> roles = new ArrayList<>();

    public Member(MemberId memberId, Password password, String name,List<Role> roles) {
        this.memberId = memberId;
        this.password = password;
        this.name = name;
        this.roles = roles;
    }

    public Member() {}

    public MemberData entityToDto(){
        return new MemberData(memberId.getId(),name);
    }


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        return this.roles.stream()
                .map((role)-> new SimpleGrantedAuthority(role.toString()))
                .collect(Collectors.toList());
    }

    @Override
    public String getPassword() {
        return this.password.getValue();
    }

    @Override
    public String getUsername() {
        return this.memberId.getId();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

JwtTokenProvider

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    @Value("${app.jwtSecret}")
    private String jwtSecret;
    private long jwtExpirationInMs = 1000L * 60 * 60 * 10;

    @PostConstruct
    public void init(){
        String secretKey = Base64.getEncoder().encodeToString(jwtSecret.getBytes(StandardCharsets.UTF_8));
    }

    // 로그인 시 사용
    public String createToken(Authentication authentication){

        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        Date now = new Date();
        String token = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("roles",authorities)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + jwtExpirationInMs))
                .signWith(SignatureAlgorithm.HS256, jwtSecret)
                .compact();
        return token;
    }

    /**
     * 로그인 후 사용자의 요청 토큰을 받아서 정보 확인(권한 포함)
     *
     * @param token
     * @return
     */
    public Authentication getAuthentication(String token){
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(jwtSecret)
                .build()
                .parseClaimsJws(token)
                .getBody();


        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("roles").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        User principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public String resolveToken(HttpServletRequest request){
        return request.getHeader("AUTH-TOKEN");
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(jwtSecret).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {

            System.err.println("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {

            System.err.println("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {

            System.err.println("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {

            System.err.println("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }

 

  • UserDetailService의 loadUserByUsername 메서드는 authenticationManagerBuilder의 authenticate를 사용할 때 호출
  • 무사히 DB조회까지 마친 후 JwtTokenProvier의 createToken(Authentication authentication)을 통해 유저 정보 및 토큰 유효 기간등을 넣고  secretKey를 통해 서명 한 뒤 리턴

 authenticationManagerBuilderd 동작 과정

  1. AuthenticationManager 구현체인 ProviderManager의 authenticate 호출
  2. 그 안에서 AuthenticationProvier 구현체인 AbstractUserDetailsAuthenticationProvider의 authenticate 호출
  3. 최종적으로 retrieveUser를 호출하고 해당 메서드 안에서 UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username); 를 호출한다.

security의 인터페이스를 통해 오버라이딩하여 구현 코드를 커스텀할 수 있고
해당 메서드들이 뱉는 예외들을 Controller Adviser에 등록하여 잡을 수도 있다.

 

 

 3. 로그인 후 인증, 인가 처리 과정 및 코드

 

 처리 과정

  1. 사용자는 로그인 후 리턴 받은 토큰을 Header에 담아 요청을 보낸다.
  2. Security Config에서 등록해 준 OncePerRequestFilter 구현체인 JwtAuthenticationFilter가 동작하게 된다.
  3. JwtAuthenticationFilter의 메서드인 doFilterInternal에서 jwtTokenProvider에게 토큰 검증을 시킨다.
  4. JwtProvider에서는 Header의 token 값을 가져와 복호화 한 뒤 유효성 검증을 하여 문제가 없으면 
    Authentication 객체를 리턴한다.
  5. JwtAuthenticationFilter에서 Authentication 객체를 SecyrityContextHolder에 저장한다.
  6. SecyrityContextHolder가 유저 정보를 가지고 있게 되므로 인증, 인가 처리가 가능해진다.

 

구현 코드

 

JwtAuthenticationFilter

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(request);     

        if(token != null && jwtTokenProvider.validateToken(token)){
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            System.err.println(authentication.getAuthorities());
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request,response);
    }
}

 

JwtTokenProvider

/**
 * 로그인 후 사용자의 요청 토큰을 받아서 정보 확인(권한 포함)
 *
 * @param token
 * @return
 */
public Authentication getAuthentication(String token){
    Claims claims = Jwts.parserBuilder()
            .setSigningKey(jwtSecret)
            .build()
            .parseClaimsJws(token)
            .getBody();


    Collection<? extends GrantedAuthority> authorities =
            Arrays.stream(claims.get("roles").toString().split(","))
                    .map(SimpleGrantedAuthority::new)
                    .collect(Collectors.toList());

    User principal = new User(claims.getSubject(), "", authorities);

    return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}

public String resolveToken(HttpServletRequest request){
    return request.getHeader("AUTH-TOKEN");
}

public boolean validateToken(String token) {
    try {
        Jwts.parserBuilder().setSigningKey(jwtSecret).build().parseClaimsJws(token);
        return true;
    } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {

        System.err.println("잘못된 JWT 서명입니다.");
    } catch (ExpiredJwtException e) {

        System.err.println("만료된 JWT 토큰입니다.");
    } catch (UnsupportedJwtException e) {

        System.err.println("지원되지 않는 JWT 토큰입니다.");
    } catch (IllegalArgumentException e) {

        System.err.println("JWT 토큰이 잘못되었습니다.");
    }
    return false;
}

 

이렇게 secyrity를 구성해 보았고, 워낙 사람마다 구현하는 방법이 다르다 보니 쉽지 않았다.
특히 security에서 제공하는 기능을 그대로 쓰는 사람과 인터페이스를 직접 오버라이딩하여 직접 구현하는 사람들도 있었지만 시큐리티 동작 흐름을 이해하고 싶어 최대한 커스텀을 하지 않고 제공 기능을 그대로 사용하려고 노력했다.

 

이후 Security 인증, 인가에 대한 Exception 처리와 테스트 코드 작성한 것에 대해 포스팅 할 예정이다.

반응형

'Spring > security' 카테고리의 다른 글

Spring Security 예외 처리  (0) 2024.04.22