본문 바로가기

Backend/Spring

[Spring/SpringBoot] SpringSecurity를 API Server에 적용하기(JWT)

안녕하세요.

오늘 포스팅에서는 Rest Api Server에 Spring Security를 적용시켜 인증된 권한을 가진 유저에게만 요청을 허용하는 방법에 대해 알아보도록 하겠습니다.

 

우선 spring security를 사용하시려면 gradle 기준 다음의 dependency를 추가하셔야 합니다.

implementation 'org.springframework.boot:spring-boot-starter-security'

 

1. Spring Security

스프링에서 인증 및 권한 부여를 쉽게 도와주는 spring security framework를 제공해줍니다. spring boot 기반 프로젝트를 진행 중이시라면 별도의 보안 관련 처리 없이 spring security를 적용하여 사용하실 수 있습니다.

 

모든 Spring의 요청은 Dispatcher Servlet이 관리합니다. Dispatcher Servlet에 대한 이해가 부족하시다면 이 링크를 참고하셔도 좋을 것 같습니다.

 

Sprint Security의 기본 작동 원리는 요청이 Dispatcher Servlet으로 전달되기 전에 Filter를 하나 추가해주는 것입니다. 해당 Filter에서 권한 인증이 완료되었으면 해당 요청이 Servlet으로 전달됩니다.

 

 

자세한 security의 작동원리에 대해서는 따로 다루도록 하겠습니다.

 

우선 이번 포스팅을 이해하는 것에 가장 중요한 것은

 

  • Authentication : 해당 사용자가 본인이 맞는지 확인
  • Authorization : 요청을 보낸 사용자가 해당 자원에 권한이 있는지를 검사

이렇게 두가지를 기본적으로 알고 계시면 좋을 것 같습니다.

 

Spring Security Filter 안에는 여러 개의 Filter가 체인 형식으로 작동합니다. Filter1-> Filter2->... -> Servlet.

많은 필터 중에 UsernamePasswordFilter라는 Filter는 해당 자원에 대한 권한이 인증되지 않으면 로그인 폼으로 자동으로 이동시켜줍니다. 하지만 Rest Api에서는 로그인 폼이 따로 존재하지 않기 때문에 해당 Filter 작동 전에 오류 로그를 사용자에게 전달해야 합니다.

 

2. JWT

저희는 유저의 권한 인증을 위해 JWT 토큰이라는 것을 활용합니다. 

https://jwt.io/introduction/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

 

간략히 소개드리면 JWT란 것은 Json 객체를 암호화하여 만든 String, 일종의 Token입니다. 변조하기 어렵고, 토큰 자체에 데이터에 대한 정보가 포함되어 있습니다. 특정 유저가 로그인을 할 시 해당 유저에 맞는 JWT토큰을 발급하고, 유저는 이 JWT토큰을 활용하여 여러 자원에 대한 접근의 인증을 할 수 있습니다.

 

3. 구현

implementation 'io.jsonwebtoken:jjwt:0.9.1'

 

JWT를 이용하기 위해 gradle 파일에 해당 dependency를 추가하셔야 합니다.

 

 

<JwtTokenProvider>

package com.SCAR.Security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.util.Base64;
import java.util.Date;
import java.util.List;

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    private String secretKey = "CustomSecretKey";

    // 토큰 유효시간 1시간
    private final long tokenValidTime = 60 * 60 * 1000L;
    private final UserDetailsService userDetailsService;

    // 객체 초기화, secretKey를 Base64로 인코딩한다.
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    // JWT 토큰 생성
    public String createToken(String userPk, List<String> roles) {
        Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
        claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) // 정보 저장
                .setIssuedAt(now) // 토큰 발행 시간 정보
                .setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey)  // 사용할 암호화 알고리즘과
                // signature 에 들어갈 secret값 세팅
                .compact();
    }

    // JWT 토큰에서 인증 정보 조회
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }

    // 토큰에서 회원 정보 추출
    public String getUserPk(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }

    // Request의 Header에서 token 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("X-AUTH-TOKEN");
    }

    // 토큰의 유효성 + 만료일자 확인
    public boolean validateToken(String jwtToken) {
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

 

JwtToken을 발급해줄 Bean을 위와 같이 정의합니다.

 

<JwtAuthenticationFilter>

package com.SCAR.Security;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
    private final JwtTokenProvider jwtTokenProvider;


    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
        // 유효한 토큰인지 확인합니다.
        if (token != null && jwtTokenProvider.validateToken(token)) {
            // 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            // SecurityContext 에 Authentication 객체를 저장합니다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
}

 

저희가 사용할 filter를 정의합니다.

 

JwtTokenProvider를 주입받아 해당 request를 jwt token으로 바꾼 뒤, 유효성 검증을 진행합니다.

 

만약 유효성 검증이 되었다면 Authentication 정보를 받아, SecurityContext에 해당 정보를 저장합니다.

 

저희가 설정한 토큰 정보로 유저를 조회하는 userDetailsService를 재정의합니다.

package com.SCAR.Account;

import com.SCAR.Authentication.AccountSecurityAdapter;
import com.SCAR.Domain.Account;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
    private final AccountRepository accountRepository;
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {

        Account account = accountRepository.findByEmail(email);
        if(account==null) throw new UsernameNotFoundException(String.format("EMAIL : [%s]를 찾을 수 없습니다", email));

        return new AccountSecurityAdapter(account);
    }
}

 

이제 SecurityConfig class를 작성해 볼게요.

 

<SecurityConfig>

package com.SCAR.Config;

import com.SCAR.Security.JwtAuthenticationFilter;
import com.SCAR.Security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable() // rest api -> 기본 설정 disable
                .csrf().disable() // csrf -> 비활성화, security 설정 시 기본값으로 활성화 되어있음
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 활용 안함
                .and()
                    .authorizeRequests()
                        .antMatchers("/auth/log-in", "/auth/sign-up").permitAll() // 가입, 로그인에 대한 권한 해제
                        .anyRequest().hasRole("USER") // 나머지 요청에 대해 USER ROLE 을 가져야만 접근 가능
                .and()
                    .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
    }
}

 

이제 거의 다 왔습니다.

 

Security에 Role 관련 권한 처리를 설정했으므로, 저희가 사용하는 User Entity에도 해당 데이터에 대한 정보가 필요합니다. 따라서 Account class에 UserDetails를 구현하도록 하겠습니다.

 

package com.SCAR.Domain;

import com.SCAR.BaseTimeEntity;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

@Entity
@Getter @EqualsAndHashCode(of = "id", callSuper = false)
@NoArgsConstructor @AllArgsConstructor @Builder
public class Account extends BaseTimeEntity implements UserDetails {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String SKKEmail;

    @Column(unique = true)
    private String email;

    @Column(unique = true)
    private String nickname;

    private String password;

    private boolean emailChecked;

    private String checkEmailToken;

    private LocalDateTime emailCheckTokenGeneratedAt;

    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private final List<String> roles = new ArrayList<>();

    @Lob @Basic(fetch = FetchType.EAGER)
    private String ProfileImage;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
    }

    @Override
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    public String getUsername() {
        return this.email;
    }

    @Override
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    public boolean isEnabled() {
        return true;
    }
}

 

JsonProperty Write Only를 적용해 주었습니다. 별도로 read 할 요청은 필요하지 않습니다.

 

인증 권한은 email로 하겠습니다. 회원가입 시, 이메일이 중복되지 않게 설계하였기 때문에 해당 값으로 인증 관련 처리를 할 수 있습니다.

 

또한 저희는 유저에 대한 Authentication 인증이 완료되었으면, 해당 유저의 객체를 받아오고 싶습니다. 따라서 Spring Security의 User를 상속받은 Adapter하나를 설정해주겠습니다.

 

package com.SCAR.Authentication;

import com.SCAR.Domain.Account;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.List;

public class AccountSecurityAdapter extends User {
    private Account account;

    public AccountSecurityAdapter(Account account) {
        super(account.getNickname(), account.getPassword(), List.of(new SimpleGrantedAuthority("ROLE_USER")));
        this.account = account;
    }
}

 

 

이제 마지막으로 Controller, Service를 통해 로그인, 회원가입을 구현해보도록 하겠습니다.

 

<AuthenticationController>

package com.SCAR.Authentication;

import com.SCAR.Account.AccountNotValidException;
import com.SCAR.Account.AccountService;
import com.SCAR.Account.SignUpForm;
import com.SCAR.Account.SignUpFormValidator;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.util.List;
import java.util.Map;

@Controller
@RequiredArgsConstructor
public class AuthenticationController {
    private final AccountService accountService;
    private final JwtTokenProvider jwtTokenProvider;
    private final PasswordEncoder passwordEncoder;
    private final SignUpFormValidator signUpFormValidator;

    @PostMapping("/auth/sign-up")
    public ResponseEntity<Map<String, String>> submitSignUp(@Valid @RequestBody SignUpForm signupForm, BindingResult bindingResult) {
        signUpFormValidator.validate(signupForm, bindingResult);
        if(bindingResult.hasErrors()) {
            List<String> errorList = accountService.getSignUpErrorList(bindingResult);
            throw new AccountNotValidException(errorList, "Custom Validator work");
        }
        return accountService.getSuccessSignUpResponse(signupForm);
    }

    @GetMapping("/auth/log-in")
    public String logIn(@RequestParam String email, @RequestParam String password) {
        return accountService.loginWithEmailAndPassword(email, password);
    }
    
}

 

<AccountService>

package com.SCAR.Account;

import com.SCAR.Domain.Account;
import com.SCAR.Authentication.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.support.DefaultMessageSourceResolvable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.BindingResult;

import java.util.*;
import java.util.stream.Collectors;

@Transactional
@Service
@RequiredArgsConstructor
public class AccountService {

    private final AccountRepository accountRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenProvider jwtTokenProvider;
    private final AuthenticationManager authenticationManager;

    public Account processNewAccount(SignUpForm signupForm) {
        // TODO send email and confirm
        return saveNewAccount(signupForm);
    }

    private Account saveNewAccount(SignUpForm signupForm) {
        Account newAccount = Account.builder()
                .email(signupForm.getEmail())
                .nickname(signupForm.getNickname())
                .roles(Collections.singletonList("ROLE_USER"))
                .password(passwordEncoder.encode(signupForm.getPassword()))
                .build();

        return accountRepository.save(newAccount);
    }

    private void getAuthentication(String email, String password) {
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(email, password);
        authenticationManager.authenticate(authenticationToken);
    }

    private String getJwtToken(Account account) {
        return jwtTokenProvider.createToken(account.getUsername(), account.getAuthorities()
                .stream().map(Object::toString).collect(Collectors.toList()));
    }

    public List<String> getSignUpErrorList(BindingResult bindingResult) {
        return bindingResult.getGlobalErrors()
                    .stream()
                    .map(DefaultMessageSourceResolvable::getDefaultMessage)
                    .collect(Collectors.toList());
    }

    public ResponseEntity<Map<String, String>> getSuccessSignUpResponse(SignUpForm signupForm) {
        Account newAccount = processNewAccount(signupForm);
        String jwtToken = loginWithEmailAndPassword(signupForm.getEmail(), signupForm.getPassword());

        Map<String, String> responseBody = new HashMap<>();
        responseBody.put("UserId", newAccount.getId().toString());
        responseBody.put("Token", jwtToken);
        return new ResponseEntity<>(responseBody, HttpStatus.CREATED);
    }

    public String loginWithEmailAndPassword(String email, String password) {
        Account account = accountRepository.findByEmail(email);
        if(account==null) {
            throw new AccountNotFoundException(String.format("Email[%s]를 찾을 수 없습니다", email));
        }
        if(!passwordEncoder.matches(password, account.getPassword())) {
            throw new IllegalArgumentException("패스워드가 일치하지 않습니다.");
        }
        getAuthentication(email, password);
        return getJwtToken(account);
    }
}

 

이제 포스트맨으로 해당 요청을 테스트해보겠습니다.

 

간단하게 Post Entity를 설정하여 Post생성부에 @AuthenticationPrincipal 어노테이션을 활용하여 유저를 인증받겠습니다.

 

package com.SCAR.Post;

import com.SCAR.Authentication.AccountSecurityAdapter;
import com.SCAR.Domain.Account;
import com.SCAR.web.dto.PostListResponseDto;
import com.SCAR.web.dto.PostResponseDto;
import com.SCAR.web.dto.PostSaveRequestDto;
import com.SCAR.web.dto.PostUpdateRequestDto;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RequiredArgsConstructor
@RestController
public class PostController {

    private final PostService postsService;

    @PostMapping("/post")
    public Long save(@RequestBody PostSaveRequestDto requestDto, @AuthenticationPrincipal Account user) {
        return postsService.save(requestDto);
    }

}

 

@RequiredArgsConstructor
@Service
public class PostService {
    private final PostRepository postRepository;

    @Transactional
    public Long save(PostSaveRequestDto requestDto) {
        return postRepository.save(requestDto.toEntity()).getId();
    }
}

 

 

 

Request Header에 JWT 토큰을 추가해주신 뒤, 메소드를 호출하시면 작동합니다.

 

*저의 글에 대한 피드백이나 지적은 언제나 환영합니다.