Backend/spring security

spring security : jwt를 이용한 로그인 구현[1]

attlet 2023. 10. 14. 01:03

프로젝트를 하면서, 로그인을 구현해야하는 단계에 왔을 때, sns로그인을 이용해 간단하게 넘길지 아니면 직접 구현할지 팀원과 의논했다.

 

spring security는 강의에서도 생각보다 자세하게 다루지 않아, 깊게 공부하려면 아예 따로 spring security강의를 찾거나 레퍼런스를 뒤지면서 처음부터 공부해야했다. 그래도 이왕 하는 김에, 처음부터 머리박으면서 구현해보는게 좋겠다는 생각으로 뛰어들었다.

 

 

로그인을 구현할 때, 먼저 세션과 토큰 방식 중 선택해야했다. 두 방식의 차이점은 크게 보면 세션은 유저 정보가 서버에 저장되고, 토큰은 유저 정보를 클라이언트에서 보낸다는 점이다. 

 

 

우리는 토큰 방식을 선택했는데, 이는 다음과 같은 이유에서 였다.

 

1.  세션같은 경우, 유저 정보를 서버가 저장한다. 유저 수가 늘수록 당연히 db에도 그 만큼 부담이 가해진다. 

 

2. 확장성이 떨어진다. 서버를 증설한 이후, 세션 정보를 저장한 서버가 아닌 서버에 접속하는 경우 인증, 인가 과정이 실패할 수 있다. 

 

 

 

 

물론 토큰을 쓰는 것이 단점이 없는 것은 아니다. 그래서 난 그 단점을 극복한 과정까지 작성하려한다.

 

 

 

jwt 토큰 방식은 기본적으로 다음과 같은 프로세스를 거친다.

 

출처 : https://velog.io/@leesomyoung/JWT-Access-token-Refresh-token

 

로그인을 하면 서버에서 db를 조회해 유저인지 확인하고, 유저가 맞다면 토큰을 준다. 이후 사용자는 그 토큰을 통해 인증, 인가를 할 수 있다.

 

토큰은 만료기간을 갖고 있어 만약 만료시간이 지나면 더 이상 인증, 인가를 할 수 없게된다. 

 

 

 

 

 

이제 이 jwt를 사용해보도록 해보자.

 

 

 

 

 

코드 작성


 

먼저 jwt 토큰 방식을 적용하기 위해 build.gradle파일에 다음과 같이 추가해준다. 

 

 

 

 

설정을 추가했으면 이제 jwt를 위한 설정 파일 부터 작성해본다.

 

 

SecurityConfig.java

@Configuration                   //spring security 설정
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig  {   //WebSecurityConfigurerAdapter 상속받아서 하는 설정 방식은 deprecated됨.
    //대신 개발자가 직접 component-based security 설정을 할 수 있도록 변경되었다. 즉 커스텀 할 설정들을 @Bean으로 등록하여 사용한다.

    private final JwtTokenProvider jwtTokenProvider;
   
    private String[] allowUrl = {         //인증, 인가 작업을 할 필요없는 url들 모아둠.
            "/v3/api-docs/**",
            "/swagger-ui/**",
            "/swagger-resources/**",
            "/sign/**",
            "/users/**"
    };
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception{

        httpSecurity.httpBasic().disable()
                .cors()
                .and()
                .csrf().disable()
                .sessionManagement()
                .sessionCreationPolicy(
                        SessionCreationPolicy.STATELESS   //restapi 기반의 동작 방식 설정. 
                )                                         // 세션이 아니니 stateless
                

                .and()
                .authorizeHttpRequests()       //authorizedRequests, antMatchers는 deprecated되서 사용 안 함.
                .requestMatchers(allowUrl).permitAll()
                .requestMatchers(HttpMethod.GET, "/boards/**").permitAll()  //boards로 시작하는 get요청은 다 허용한다는 의미.
//                .anyRequest().anonymous()   //기타 요청은 인증을 받지 않아도 모두 접근 가능.
                .anyRequest().authenticated()

                .and()
                .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())            //권한을 확인하는 과정에서 예외발생 시 전달할 예외 처리

                .and()
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())            //인증과정에서 발생하는 예외 처리

                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); //후자의 필터로 가기전 Jwt필터를 먼저 거치겠다는 것.


        return httpSecurity.build();
    }
  }

 

주석을 통해 코드 설명이 작성 되어있다. 이전 코드들을 보면 antMatcher같이 deprecated된 코드를 사용하는 경우가 있어 좀 찾아봐야 했었다.  

 

 

이 설정을 통해 어느 url을 인증 없이 허용할 지 결정하고, 커스텀한 deniedHandler, authenticationEntryPoint를 적용할 수 있다.

 

 

 

나 같은 경우에는 문자열을 만들어 허용할 url들을 모아두는 방식이 많이 편할 것 같다는 생각이 들어 적용했다.

 

 

위에서 마지막 부분에 등장하는 JwtAuthenticationFilter는 다음과 같다.

 

 

 

JwtAuthenticationFilter.java

public class JwtAuthenticationFilter extends OncePerRequestFilter {  //jwt토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정
    private final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final JwtTokenProvider jwtTokenProvider;

    @Autowired
    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);

        String ipAddresses = request.getHeader("x-forwarded-for");
        String ipAddress = new String();

        if(!Objects.isNull(ipAddresses)){
            ipAddress = Arrays.stream(ipAddresses.split(","))  // 최초 IP
                    .findFirst()                                    //로드밸런서, 프록시를 거치면 그 ip들이 열거됨. 가장 최초의 클라이언트 ip는 이렇게 조회.
                    .orElse("");
        }
        else{
            ipAddress = request.getRemoteAddr();
        }

        log.info("ip : " + ipAddress + " " + ipAddresses);
        log.info("JwtAuthenticationFilter doFilterInternal ==> token 값 추출 : " + token);  //request에서 토큰 추출.


        if(token != null && jwtTokenProvider.validateToken(token)){
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);   //Authentication객체를 생성해 SecurityContextHolder에 추가
            log.info("JwtAuthenticationFilter doFilterInternal ==> token 값 유효성 체크 성공");
        }

        filterChain.doFilter(request, response);  //서블릿을 실행하는 메서드. 이 메서드를 기준으로 앞서 작성된 코드는 서블릿이 실행되기 전에 실행됨.

    }
}

 

spring security에서 기본적으로 인증할 때 동작하는 필터인 UsernamePasswordAuthenticationFilter는 인증, 인가에 실패하면 로그인 form을 리턴한다. 우리 프로젝트는 rest api 기반으로 동작하니, jwt용 필터를 따로 작성해 적용했다. 

 

 

인터페이스 OncePerRequestFilter를 통해 필터를 쉽게 구현할 수 있었다. 또 다른 방법으로 GenericFilterBean이라는 것이 존재했는데, 찾아보니 이 인터페이스로 구현하면 dispatch시 필터가 두 번 실행되는 문제가 있다고 한다. 자세한 내용은 다음 글을 참조하자.

 

https://g4daclom.tistory.com/115

 

 

 

 

 

http 메시지가 필터에 도달하면, 메시지에 있는 토큰이 유효한지 검사한다. 그 다음 그 토큰으로 유저를 찾아내는 데 성공하면, SecurityContextholder에 authentication객체를 추가하여 인증되도록 한다.

 

 

 

이제 유저정보 기반으로 토큰을 생성하고, 만료시간을 설정하는 jwtTokenProvider를 작성해보자. 이 부분은 코드가 길어 부분을 나눴다.

 

 

 

JwtTokenProvider.java

 

@Component
@RequiredArgsConstructor    //생성자로 의존성 주입받을 때 final 필드들은 이 어노테이션으로 주입 가능
public class JwtTokenProvider {
    private final Logger log = LoggerFactory.getLogger(JwtTokenProvider.class);
    private final UserDetailsService userDetailsService;
    private final RedisTemplate redisTemplate;
    
    @Value("${springboot.jwt.secret}")
    private String secretKey = "secretKey";
    
    private final long tokenValidMilliSecond = 1000L * 60 * 60;
    private final long refreshValidMilliSecond = tokenValidMilliSecond * 24;
    
    @PostConstruct        //해당 객체가 주입된 이후 수행되는 메서드 지정
    protected void init(){
        log.info("init ==> secret 키 초기화 시작");
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));  //secret key를 base64형식으로 인코딩한다.
        log.info("init ==> secret 키 초기화 완료");
    }

 

LoggerFactory는 로그를 찍기 위한 것이다. userDetailService를 이용해 유저 정보에 접근해 실제로 회원가입한 유저인지 체크한다. 

 

secretkey는 application.yml파일에서 가져오고, 만약 없다면 secretKey로 지정된다. 

 

 

@PostConstruct어노테이션은 이 provider객체가 빈으로 주입되어 사용되기 시작할 때 실행되도록 메서드를 설정한다. init메서드가 실행되면 secretkey를 base64형식으로 인코딩해서 변환한다. 

 

 

public String createToken(String username, List<String> roles){
    log.info("JwtToken createToken ==> 토큰 생성 시작");
    Claims claims = Jwts.claims().setSubject(username);  //sub속성 값을 추가.
    claims.put("roles", roles);

    Date now = new Date();
    String token = Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)           //iat 속성에 값 추가. jwt가 발급된 시간.
            .setExpiration(new Date(now.getTime() + tokenValidMilliSecond))  //jwt의 만료시간
            .signWith(SignatureAlgorithm.HS256, secretKey)    //secret값 세팅. 암호화 알고리즘 적용.
            .compact();  //최종적으로 사용자에게 전달할 형태로 jwt를 컴팩트.

    log.info("JwtToken createToken ==> 토큰 생성 완료");
    return token;
}

 

그 다음 토큰을 생성하는 부분이다.  jwt토큰은 3가지 부분으로 나뉘는데, 그 중 payload(내용)부분에 여러 정보들이 저장될 수 있다. json과 비슷하게 속성과 값으로 저장할 수 있는데, 이 속성을 claim이라 부른다. 

 

setSubject를 이용해 sub속성을 로그인 요청할 때 입력한 아이디 username으로 등록한다.

 

 

sub속성은 이 토큰의 제목(subject)를 나타낸다. 추가로 roles를 추가하는 것은 이 유저의 권한이 어디인지 나타낸다. 

 

 

토큰을 마지막에 암호화 알고리즘을 적용해서 파싱하지 않고는 정보를 알아볼 수 없도록 변환한다. 

 

 

 

public Authentication getAuthentication(String token){   //인증이 성공하면 SecurityContextHolder에 저장할 authentication을 생성하는 역할.
    log.info("JwtToken getAuthentication ==> 토큰 인증 시작" );
    UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
    return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}

 

그 다음, 필터를 거칠 때 필요한 authentication객체를 리턴할 메서드이다. loadUserByUsername은 이름 그대로 username을 기반으로 user엔티티를 찾는 메서드이다. 만약 유저가 있다면 그 유저 정보를 토대로 인증 정보를 리턴한다. 

 

 

public String resolveToken(HttpServletRequest request){
    log.info("JwtToken resolveToken ==> http 헤더로부터 token 값 추출 시작");
    return request.getHeader("X-AUTH-TOKEN");    //헤더값으로 전달된 X-AUTH-TOKEN값을 가져와 리턴한다. 헤더 이름은 임의로 변경가능
}

 public boolean validateToken(String token){
        log.info("JwtToken validateToken ==> 토큰 유효성 체크 시작");
        try{
           
            Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(secretKey).build()  //jws = json web signature(서명)
                    .parseClaimsJws(token);  //header, payload 파싱해서 json 형태로 변환 -> 지정된 비밀키로 서명 검증.

            return !claims.getBody().getExpiration().before(new Date());
        } catch(Exception e){
            log.info("JwtToken validateToken ==> 예외 발생");
            return false;
        }
    }

 

http 메시지가 오면 그 메시지의 헤더를 참조해 토큰을 추출하는 resolveToken메서드와, 토큰이 정말 유효한지 서버의 비밀키와 만료기간을 통해 검증하는 validateToken메서드이다. 

 

validate과정은 쉽게 보면 토큰 생성할 때 사용한 비밀키를 이용해 요청으로 온 토큰을 파싱하는 것이다. 

 

 

파싱한 후 유효한 지 검증하는 방법은 토큰을 생성할 때 설정한 유효시간을 이용하는 것이다. 

 

토큰을 생성할 때 우리는 유효시간을 정했는데, 그것과 지금 시간을 비교해서 유효기간이 지났는지 boolean타입으로 알려주는 메서드인 것이다. 

 

 

 

 

이렇게 jwtTokenProvider는 다 작성이 끝났다. 이제 다음은 로그인하는 사용자에 대한 정보를 저장하는 user엔티티를 수정해보려 한다.

 

 

사용자 정보를 저장하는 엔티티가 UserDetails를 구현하도록 변경했다.

 

@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString(callSuper = true)
@Table(name = "user") //테이블과 매핑
@SQLDelete(sql = "UPDATE user SET deleted = true WHERE id = ?")
@Where(clause = "deleted = false")
public class User extends BaseEntity implements UserDetails{
    
    
    
    .....
    
    //UserDetail 구현 부분
    
   @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<String> roles = new ArrayList<>();

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public String getUsername(){
        return this.username;
    }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {  //계정의 권한들을 리턴
        return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonExpired() {                 //계정이 만료되었는지를 나타냄. true는 만료되지 않음을 뜻함
        return true;
    }
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {                  //계정이 잠겨잇는지를 나타냄, true는 잠기지 않음을 뜻함
        return true;
    }
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {             //비밀번호가 만드료되었는지를 나타냄, true는 만료되지 않음을 뜻함
        return true;
    }
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isEnabled() {                           //계정이 활성화되었는지 나타냄, true는 활성화 상태
        return true;
    }

 

 

원래 있던 user엔티티에, UserDetails인터페이스를 구현하도록 했다. 저 메서드들 중 제대로 사용한 건 getAutorities정도 뿐이긴하지만.. 

 

 

 

그 다음, user를 위한 비즈니스 로직을 작성한 userService에 UserDetailsService를 추가로 구현하도록 했다.

 

 

 

 

 

UserDetailsService의 loadUserByUsername을 추가로 구현한다.  간단하게 username 으로 user 데이터를 찾는 메서드이다. username은 우리는 id로 사용하고 있으니, 만약 바꾸고 싶으면 username을 바꾸면 된다.

 

 

 

 

 

user관련 설정이 끝났다. 다음 과정은 글이 길어져 2편으로 이어진다.