본문 바로가기
프로젝트

GiftFunding) Spring Security + JWT를 이용한 로그인 구현

by son_i 2023. 11. 23.
728x90

Spring Security 는 서블릿 필터를 기반으로 동작하며 Dispatcher Servlet 앞에 필터가 배치되어있다.

 - Dispatcher Servlet 이란 ?) HTTP 프로토콜로 들어오는 모든 요청을 가장 먼저 받아 적합한 컨트롤러에 위임하는 프론트 컨트롤러. Front Controller는 주로 서블릿 컨테이너의 제일 앞에서 서버로 들어오는 클라이언트의 모든 요청을 받아서 처리해주는 컨트롤러로써, MVC 구조에서 함께 사용되는 디자인 패턴이다.

FilterChain

: 서블릿 컨테이너에서 관리하는 ApplicationFilterChain.

 client -> application 요청시 서블릿 컨테이너는 URI를 확인해 필터와 서블릿을 매핑.

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

 

- DelegatingFilterProxy

 : DelegatingFilterProxy는 서블릿 컨테이너의 생명주기와 스프링 애플리케이션 컨텍스트 사이에서 다리 역할을 수행하는 필터 구현체이다. 표준 서블릿 필터를 구현하고 있으며, 역할을 위임할 필터체인 프록시(스프링부트 자동설정에 의해 자동 생성)를 내부에 가진다.

  

  *Spring Security에서는 DelegatingFilterProxy를 통해 표준 서블릿 컨테이너 메커니즘으로 등록하는 필터 뿐 아니라 Spring Bean으로 구현한 모든 필터를 등록한다.

(일반적인 스프링 서블릿 컨테이너는 Spring Bean으로 구현된 필터를 등록하지 않는다.)

 

- FilterChainProxy

스프링 빈으로 설정되어 DelegatingFilterProxy를 통해 동작함.

 

 : 스프링 시큐리티에서 제공하는 필터로서 보안 필터 체인을 통해 많은 보안 필터를 사용할 수 있다.

  보안 필터 체인은 List 형식으로 담을 수 있게 되어있어 URI 패턴에 따라 특정 보안 필터 체인을 선택해서 사용.

  * 보안 필터 체인들을 필터체인 프록시에 등록해놓고 DelegatingFilterProxy에 등록해서 사용.

 

 ** 보안 필터 체인 : WebSecurityConfigurerAdapter 클래스를 상속받아 설정.

    필터 체인 프록시는 여러 개의 보안 필터 체인을 가질 수 있는데 여러 보안 필터체인을 만들려면 WebSecurityConfigurerAdapter 클래스를 상속받는 클래스를 여러 개 생성하면 됨. 

   단, 상속받은 클래스에서 @Order로 순서를 정의하는 것이 중요하다.

 

다수의 보안 필터 체인에서 요청 URL에 맞는 보안 필터 체인을 필터체인프록시가 선택해서 실행.

* 별도 설정 없이는 UsernamePasswordAuthenticationFilter 통해 인증을 처리함.

 


<UsernamePasswordAuthenticationFilter의 동작과정>

1. 클라이언트로부터 요청을 받으면 서블릿 필터에[서 Security Filter Chain으로 작업이 위임된다.

 그 중 UsernamePasswordAuthenticationFilter(AuthenticationFilter)에서 인증을 처리한다.

 

2.  AuthenticationFilter는 요청객체인 HttpServletRequest에서 username 과 password를 추출해 토큰을 생성한다.

(UsernamePasswordAuthenticationFilter는 AuthenticationFilter인터페이스의 구현체)

 

3. UsernamePasswordAuthenticationFilter이 생성한 Token을 AthenticationManager에 전달한다. 

(AuthenticationManager는 인터페이스로 일반적으로 ProviderManager를 구현체로 사용한다.)

 

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

 

5. AuthenticationProvider는 토큰의 정보를 UserDetailsService로 전달한다.

 

6. UserDetailsService는 전달받은 정보를 통해 DB에서 일치하는 사용자를 찾아 UserDetails 객체를 생성한다.

 

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

 

9. ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달한다.

 

10. AutenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장한다.

 

여기 나오는 UserDetailsService와 UserDetails는 우리가 만들 서비스와 엔티티에 구현해서 메소드를 오버라이딩 해줘야함 

 

UserDetailsService

인터페이스로 데이터베이스에서 회원정보를 가져오는 역할을 함.

  loadByUserName() 메소드를 구현해줘야하며, 회원정보를 조회하여 사용자의 권한을 갖는 UserDetail 인터페이스를 반환.

 

UserDetails

인터페이스로 스프링 시큐리티에서 회원의 정보를 담기 위해서 사용.

  스프링 시큐리티에서 제공하는 User 클래스를 제공

 


로그인을 위해서 구현해줘야 할 것들은

1. 서비스 implements UserDetailsService

2. 엔티티 implements UserDetails

3. 토큰을 생성하고 반환할 TokenProvider

4. 인증 구현을 위해서 JwtAuthenticationFilter 구현

5. 스프링 시큐리티와 관련된 SecurityConfig 설정.

 

가 있다.

 


<구현>

1. UserServiceImpl

package com.soeun.GiftFunding.service;

import static com.soeun.GiftFunding.type.ErrorCode.PASSWORD_UNMATCHED;
import static com.soeun.GiftFunding.type.ErrorCode.USER_DUPLICATED;
import static com.soeun.GiftFunding.type.ErrorCode.USER_NOT_FOUND;

import com.soeun.GiftFunding.dto.Signin;
import com.soeun.GiftFunding.dto.Signin.Response;
import com.soeun.GiftFunding.dto.Signup;
import com.soeun.GiftFunding.dto.Signup.Request;
import com.soeun.GiftFunding.entity.User;
import com.soeun.GiftFunding.exception.UserException;
import com.soeun.GiftFunding.repository.UserRepository;
import com.soeun.GiftFunding.type.ErrorCode;
import java.time.LocalDate;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService, UserDetailsService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return userRepository.findByUsername(username)
            .orElseThrow(() -> new UserException(USER_NOT_FOUND));
    }
    @Override
    public Signup.Response singUp(Signup.Request request) {

        validateDuplicated(request);

        request.setPassword(passwordEncoder.encode(request.getPassword()));
        userRepository.save(request.toEntity());

        log.info("{} 회원가입", request.getEmail());

        return Signup.Response.toResponse(request.getUserName());
    }

    private void validateDuplicated(Request request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new UserException(USER_DUPLICATED);
        }
    }

    @Override
    public Response signIn(Signin.Request request) {
        User user = userRepository.findByEmail(request.getEmail())
            .orElseThrow(() -> new UserException(USER_NOT_FOUND));

        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
            throw new UserException(PASSWORD_UNMATCHED);
        }
        return null;
    }

}

- loadByUsername() 메소드를 오버라이딩하여 db에서 회원정보를 찾아 UserDetails객체로 반환하게 했다.

 여기서 필요한 User 엔티티가 UserDetails를 상속받고 있지 않다면 에러가 난다.

 

2. User 엔티티

 

package com.soeun.GiftFunding.entity;

import com.soeun.GiftFunding.type.Authority;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collection;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.EntityListeners;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@EntityListeners(AuditingEntityListener.class)
public class User implements UserDetails{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String userName;

    @Column
    private String phone;

    @Column
    private String email;

    @Column
    private String password;

    @Column
    private String address;

    @Column
    private LocalDate birthDay;

    @Enumerated(EnumType.STRING)
    private Authority role;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;
    @LastModifiedDate
    private LocalDateTime updatedAt;


    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    @Override
    public String getUsername() {
        return null;
    }

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

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

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

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

- UserDetails 인터페이스는 오버라이딩 할 수 있는 아래와 같은 메소드를 가지고 있다.

- getAuthorities() : 계정이 가지고 있는 권한 목록을 리턴.
- getPassword() : 계정의 비밀번호를 리턴.
- getUsername() : 계정의 이름을 리턴. 일반적으로 아이디를 리턴.
- isAccountNonExpired() : 계정이 만료됐는지 리턴. true는 만료되지 않았다는 의미.
- isAccountNonLocked() : 계정이 잠겨있는지 리턴. true는 잠기지 않았다는 의미.
- isCredentialNonExpired() : 비밀번호가 만료됐는지 리턴. true는 만료되지 않았다는 의미.
- isEnabled() : 계정이 활성화 돼있는지 리턴. true는 활성화 상태를 의미.

 

 

3. TokenProvider 구현

build.gradle에 implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' 의존성 추가

package com.soeun.GiftFunding.security;

import com.soeun.GiftFunding.service.UserService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

@Component
@RequiredArgsConstructor
public class TokenProvider {
    private static final long TOKEN_EXPIRE_TIME = 1000 * 60 * 60;
    private static final String KEY_ROLE = "role";

    private final UserService userService;
    @Value("${spring.jwt.secret}")
    private String secretKey;

    public String generateToken(String mail, String role) {
        Claims claims = Jwts.claims().setSubject(mail);
        claims.put(KEY_ROLE, role);

        Date now = new Date();
        Date expiredDate = new Date(now.getTime() + TOKEN_EXPIRE_TIME);

        return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(expiredDate)
            .signWith(SignatureAlgorithm.HS512, this.secretKey)
            .compact();
    }

    public Authentication getAuthentication(String jwt) {
        UserDetails userDetails =
            this.userService.loadUserByUsername(this.getUsername(jwt));

        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getUsername(String jwt) {
        return this.parseClaims(jwt).getSubject();
    }
    public boolean validateToken(String token) {
        if (!StringUtils.hasText(token)) return false;

        Claims claims = this.parseClaims(token);
        return !claims.getExpiration().before(new Date());
    }
    private Claims parseClaims(String jwt) {
        try {
            return Jwts.parser().setSigningKey(this.secretKey)
                .parseClaimsJws(jwt).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

- getAuthentication() 메소드 : 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성하는 역할. Authentication을 구현하는 편한 방법은 UsernamePasswordAuthenticationToken을 사용하는 것.

 

UsernamePasswordAuthenticationToken 클래스를 사용하려면 초기화를 위한 UserDetails가 필요함.

이 UserDetails 객체는 UserDetailsService를 통해 가져오게 됨. 가져올 때 사용되는 Username은 getUsername()을 통해 가져온다.

 

- getUsername() 메소드 : parseClaims를 통해 클레임을 가져와서 토큰을 생성할 때 넣었던 subject 값을 반환.

 

- parseClaims() 메소드 : Jwts.parser()를 통해 secretKey를 설정하고 클레임을 추출해서 반환.

 

- validateToken() 메소드 : 토큰을 전달받아 유효기간을 체크하고 boolean 타입의 값을 리턴.

 

4. JwtAuthenticationFilter 구현

package com.soeun.GiftFunding.security;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    public static final String TOKEN_HEADER = "Authorization";
    public static final String TOKEN_PREFIX = "Bearer ";

    private final TokenProvider tokenProvider;


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        String token = this.resolveTokenFromRequest(request);

        if (StringUtils.hasText(token) && this.tokenProvider.validateToken(token)) {
            Authentication auth = this.tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);

            log.info(String.format("[%s] -> %s",
                this.tokenProvider.getUsername(token),
                request.getRequestURI()));
        }

        filterChain.doFilter(request, response);
    }

    private String resolveTokenFromRequest(HttpServletRequest request) {
        String token = request.getHeader(TOKEN_HEADER);

        if (!ObjectUtils.isEmpty(token) && token.startsWith(TOKEN_PREFIX)) {
            return token.substring(TOKEN_PREFIX.length());
        }
        return null;
    }
}

- OncePerRequestFilter : 필터를 상속받아 사용. (한 요청당 한 번씩 필터를 거침)

 

- doFilterInternal() 메소드 : OncePerRequestFilter로부터 오버라이딩한 메소드.

 

  마지막에 doFilter() 메소드는 서블릿을 실행하는 메서드로 doFilter() 기준으로 ㅇ랖에 작성한 코드는 서블릿이 실행되기 전에 실행되고, 뒤에 작성한 코드는 서블릿이 실행된 후에 실행.

 

 내부 로직에는 resolveTokenFromRequest를 통해 요청으로부터 헤더에서 토큰을 추출해와서 유효성을 검사함.

  토큰이 유효하면 Authentication 객체를 만들어서 SecurityContextHolder에 추가함.

 

5. SecurityConfig 구현

package com.soeun.GiftFunding.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .httpBasic().disable()
            .csrf().disable()
            .sessionManagement()
            .sessionCreationPolicy(
                SessionCreationPolicy.STATELESS)
            .and()
            .authorizeRequests()
            .antMatchers("/user/signup", "/user/singin")
            .permitAll()
        
            //상품 검색에 대해서는 로그인 안 한 사용자도 가능하도록 옵션추가
            
            .anyRequest().hasRole("USER");
    }

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/css/**");
    }
}

주요 메소드는 두 가지

WebSecurity 파라미터를 받은 configure() 메소드와 HttpSecurity 파라미터를 받은 configure()메소드

 

- HttpSecurity를 설정하는 configure() 메소드 : 스프링 시큐리티의 설정은 대부분 HttpSecurity를 통해 진행되며 대표적인 기능은 다음과 같다.

 

  • 리소스 접근 권한 설정
  • 인증 실패 시 발생하는 예외처리
  • 인증 로직 커스터마이징
  • csrf, cors등의 스프링 시큐리티 설정

 - httpBasic().disable() : UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화

 

 - csrf().disable() : REST API에서는 *CSRF 보안이 필요없어서 비활성화. 

스프링 시큐리티의 csrf() 메서드는 기본적으로 CSRF 토큰을 발급해서 클라이언트로부터 요청을 받을 때마다 토큰을 검증하는 방식으로 동작. 브라우저 사용환경이 아니면 비활성화해도 문제 없음.

 

- sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : REST API 기반 애플리케이션의 동작방식을 설정. JWT로 인증 처리할 때 세션 인증정보는 서버에 담아놓을 필요가 없어서  STATELESS 설정.

 

- authorizeRequest() : 애플리케이션에 들어오는 요청에 대한 사용 권한 체크. antMatcher()메서드는 antPattern을 통해 권한을 설정하는 역할을 함. 

 

 

  * CSRF(Cross-Site Request Forgery)  : 사이트간 요청위조를 의미하며 웹 애플리케이션의 취약점 중 하나로서 사용자가 자신의 의지와 무관하게 웹 애플리케이션을 대상으로 공격자가 의도한 행동을 함으로써 특정 페이지의 보안을 취약하게 만들거나 수정, 삭제등을 하는 공격 방법.

728x90