[Spring Boot] Spring Security Form Login + JWT 인증 구현

임원재·2024년 7월 15일
0

SpringBoot

목록 보기
5/19
post-thumbnail
  • Spring Security에서 Form Login을 구현을 해보았다.

[Spring Boot] Spring Security - Form Login 구현

  • 해당 구현은 세션을 이용한 Form Login이므로 세션을 사용하지 않는 JWT 인증 방식을 사용하는 Form Login으로 변환할 예정이다.

기존의 Form Login 부연 설명 (세션 사용)

  • 저번에 구현한 Form Login을 정리해보자
  • 아래 다이어그램은 이해한 만큼 작성한, Spring Security에서 기본으로 제공하는 Form Login에서 로그인 요청의 기본 흐름이다.
  • Spring Security에서는 모든 요청을 DispatcherServlet으로 보내지 않고 인증과 인가를 수행하는 Filter들의 집한, FilterChain을 먼저 거치게 된다.
  • UsernamePasswordAuthenticationFilter라는 이름의 필터에서 로그인 관련 로직을 수행한다. 입력한 usernamepassword를 가지고 UsernamePasswordAuthenticationToken을 만들어 AuthenticationManager로 전달한다.
  • AuthenticationManager에서는 usernamepassword를 가지고 해당 멤버가 존재하는지, 비밀번호를 옳게 입력했는지 등에 관한 인가 작업을 진행한다. 최종으로 인가 승인이 나면 Authentication라는 객체를 생성하여 SecurityContext에 저장한다.
  • AuthenticationManager에서 username으로 해당 멤버가 존재하는지 파악할 필요가 있다. 이때 어떤 엔티티에서 어떤 로직으로 찾을것인지 커스텀하는 서비스 클래스가 바로 UserDetailsService이다.
    @Service
    @RequiredArgsConstructor
    public class CustomUserDetailsService implements UserDetailsService {
    
        private final MemberRepository memberRepository;
    
        @Override
        public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
            return memberRepository.findByEmail(email)
                    .orElseThrow(() -> new UsernameNotFoundException("Member Not Found"));
        }
    }
    • 이와 같이 구현함으로써, Member 엔티티를 회원으로 설정할 수 있다.
    • 해당 코드에서는 db에 저장한 Member 엔티티에서 email을 통해 회원을 찾음을 알 수 있다.
  • UserDetails는 자신이 사용할 Member(혹은 User)엔티티에 username, pssword, Authorization 등의 필드를 추가하여 폼로그인을 지원하도록 하는 인터페이스이다. UserDetails를 implement하고 메서드를 Override하여 구현 가능하다.
    @Entity
    @Getter
    @Setter
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public class Member implements UserDetails {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "member_id")
        private Long memberId;
    
        @Column(name = "email", nullable = false, unique = true)
        private String email;
    
        @Column(name = "password", nullable = false)
        private String password;
    
        @Enumerated(EnumType.STRING)
        @Column(name = "role", nullable = false)
        private Role role;
    
        @Enumerated(EnumType.STRING)
        @Column(name = "type", nullable = false)
        private Type type;
    
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return List.of(new SimpleGrantedAuthority(Role.USER.toString()));
        }
    
        @Override
        public String getUsername() {
            return email;
        }
    
        @Override
        public String getPassword() {
            return password;
        }
    }
  • AuthenticationManger에서 인증이 완료되면 Authentication객체를 반환하여 SecurityContext에 저장한다.
  • SecurityContext는 현재 인증된 사용자와 관련된 모든 정보를 저장하고 있다. SecurityContextHolder를 통해 접근 가능하며, Authentication객체를 저장하고 이를 통해 인증된 사용자 정보를 제공한다. 즉, 서버 하나에 SecurityContext하나가 존재한다는 것이다. 이는 각 사용자마다의 Authentication에 따라 설정하고 해제하는 과정에서 성능의 저하가 이루어질 수 있다.
  • 또한 로그인 후 인증 정보를 일정 시간동안 유지하는 방식을 세션에 저장하는 방식을 사용한다. 세션의 사용 또한 사용자의 증가에 따라 소비되는 서버의 리소스 또한 증가되는 문제를 야기시킨다.

JWT

  • 기본적으로 세션 + 쿠키를 사용하는 Spring Security의 Form Login을 보완하기 위해 JWT인증 방식을 통해 세션을 사용하지 않고 Form Login을 구현 가능하다.

JWT

  • JWT란 JSON Web Token의 약자로, 주로 인증과 정보 교환에 사용되는 JSON기반의 토큰이다.
  • 클라이언트와 서버 간의 Stateless인증을 가능하게 하므로 Stateful인증을 사용하는 세션의 대안으로 사용된다.

구성요소

  • 3 개의 구성요소로 나뉘며, 각 부분은 마침표로 구분된다.
  1. Header : 토큰의 타입, 알고리즘 정보를 포함
  2. Payload : 토큰의 데이터 (Claims)
    • Claims는 JWT에서 미리 등록한 Claims와 사용자 지정 Claims가 존재한다.
    • iss (발급자), exp (만료 시간), sub (주제), aud (청중) 등의 미리 등록된 Claims가 존재한다.
  3. Signature : 토큰의 무결성(정확성, 일관성, 유효성이 유지되는 것)을 확인하기 위한 부분
    • header와 payload부분을 결합 후 지정된 알고리즘과 별도의 비밀 키로 해싱하여 생성된다.
  • 해당 웹사이트에서 JWT토큰 생성과 무결성 검사가 가능하다.

JWT.IO

  • 다음에서 Decoded부분을 보면 header, payload, signature 세 부분으로 나뉜 것을 볼 수 있다.

  • 해당 정보에서 전하고자 하는 정보를 담아 보내면 수신측에서 이를 decode하여 확인 가능하다.

  • header에서 알고리즘 서명(alg)은 HS256, 토큰 타입(typ)은 JWT이다.

  • payload에서는 전달하고자 하는 값을 담는다.

    • 주제 (sub) : 1234567890
    • 이름 (name) : John Doe
    • 생성시간 (iat) : 1516239022
  • your-256-bit-secret부분에 유저가 설정한 스트링 형태의 코드를 입력하고 해싱하여 신뢰할 수 있는 발급자에 의해 생성되었는지 확인하는데 사용된다.

  • 주어진 사이트에서 생성한 정보를 인코딩하여 JWT토큰을 생성 가능하다.

  • 이때 인코딩한 JWT토큰을 분석하여 검증된 토큰임을 확인하면 Signature Verified를 보여준다.

    다음은 JWT의 검증 요소이다.

    1. JWT 구조 확인 : 토큰이 header, payload, signature로 구성된 형식인지 확인
    2. signature 검증 : header와 payload를 사용해 signature를 생성하고 토큰의 signature와 비교
    3. claim 검증 : payload에 포함된 claim(만료시간, 발급자 등)을 검증
    private String createAccessToken(MemberInfoDto memberInfoDto, Date expirationTime) {
        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject("AccessToken")
                .setIssuedAt(new Date())
                .setExpiration(expirationTime)
                .claim("memberId", memberInfoDto.getMemberId())
                .claim("email", memberInfoDto.getEmail())
                .claim("role", memberInfoDto.getRole())
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }
  • Spring Boot에서 jwt 라이브러리를 사용해 JWT 토큰을 생성한 예시이다. builder()패턴을 사용하여 이와 같은 방식으로 JWT 토큰을 생성할 예정이다.

  • jwt의 버전에 따라 JWT를 빌드하는 메서드가 달라질 수 있다.

    //jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
  • build.gradle에 위와 같은 버전으로 JWT 인증을 구현하였다.

Form Login (JWT 방식)

개요

  • 폼 로그인에서 JWT방식을 사용하는 방식에서 인증을 위한 JWT토큰의 이름을 accessToken이라고 부르겠다. 우리는 로그인 시 accessToken을 HTTP헤더에 담아 보내 인증하게 된다. 이렇게 서버는 전달받은 accessToken을 decode하여 payload부분의 claims를 사용해 accessToken의 주인을 판별한다. 이때 claims는 사용자를 식별할 수 있는 claim이어야 한다.

  • 이때 payload부분은 JWT 토큰을 디코딩만 한다면 누구나 열람할 수 있는 개인정보이다. 그러므로 비밀번호와 같은 중요한 개인정보를 claims으로 사용하는 것은 위험하다.

또한, accessToken의 탈취 위험이 있다. 로그인과 별개로, accessToken만 가지고 있으면 서버는 탈취자를 탈취당한 사람으로 인식하게 된다.

  • 이를 해결하기 위한 방안으로, accessToken의 유효시간을 짧게 하고, 비교적 긴 유효시간을 가진 refreshToken 총 두 개의 Token을 운용하는 방법이다. 시나리오는 다음과 같다.
    1. 클라이언트가 인증에 성공하여 accessTokenrefreshToken 두 개를 받는다.
    2. 클라이언트는 accessToken의 유효시간 동안 인증된 사용자이며, 유효시간이 지나면 인증되지 않은 사용자로 전환된다.
    3. 이에 클라이언트는 accessToken이 아닌 refreshToken을 입력하여 요청을 보낸다.
    4. 서버는 accessToken이 아니라 refreshToken이 왔음을 확인 후 아래와 같은 행동을 취한다. (혹은 인증 만료된 accessToken을 전송하여 인증 만료되었음을 확인 후 accessToken을 재발급하는 api를 사용하는 방법도 존재한다.)
      • refreshToken만료 X 시 : accessToken을 재발급하여 클라이언트로 전송
      • refreshToken만료 시 : 이때는 다시 로그인을 진행하여 accessTokenrefreshToken을 다시 발급받아야 한다.

구현 기능

urlmethoddiscription
/api/v1/auth/signupPOST회원가입 성공 시 "Sign Up Success” 반환
/api/v1/auth/loginPOST로그인 성공 시 JwtInfoDto 반환 (accessToken, refreshToken)
/api/v1/jwt/reissuePOSTrefresh토큰을 전송하여 새로운 accessToken 생성 (JwtInfoDto 반환)
  • 실제 View를 사용해 실제 로그인 흐름을 구현하고 싶었으나, 클라이언트 측에서 accessToken을 헤더에 넣어야 하므로 api로 구현할 계획이다.
  • JWT를 이용한 로그인의 인증 흐름은 다음과 같다.

  • JWT를 통한 인증을 하기 위해서는, Spring Security Form Login에서 기본으로 사용되는 UsernamePasswordAuthenticationFilter에서의 인증 대신 JWT를 사용하여 인증을 진행하는 커스텀 필터인 JwtAuthFilter를 생성하여 Filter로 사용해야 한다.
    • JwtAuthFilter에서 헤더에 담긴 accessToken을 파싱하여 유효성 검증 후, accessToken으로 Authentication 객체를 생성하여 인증정보를 관리하는 SecurityContext에 넣어 인증을 완료한다.
    • 즉, 세션을 사용하는 기존의 Form Login에서는 UsernamePasswordAuthenticationFilter에서 Authentication객체를 SecurityContext에 넣는 역할을 하였다.
    • 해당 역할을 JwtAuthenticationFilter에 위임했다고 보면 될 것 같다.

Member

  • 기존의 폼로그인 구현에서는 MemberUserDetails를 implement하여 Member엔티티 내에서 Authorities와 username, password를 다루게 하였다. 이렇게 했더니 Member엔티티가 가지는 책임이 데이터베이스와의 상호작용과 보안 관련 역할까지 가지게 되었다. 이는 객체 지향에서 단일 책임 원칙에 위배된다 생각하여 UserDetailsCustomUserDetails로 구현하여 MemberInfoDto라는 dto 클래스를 메서드 필드로 선언하여 사용하기로 결정하였다.
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long memberId;

    @Column(name = "email", nullable = false, unique = true)
    private String email;

    @Column(name = "password", nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(name = "role", nullable = false)
    private Role role;

    @Enumerated(EnumType.STRING)
    @Column(name = "type", nullable = false)
    private Type type;

    public MemberInfoDto toMemberInfoDto() {
        return MemberInfoDto.builder()
                .memberId(getMemberId())
                .email(getEmail())
                .password(getPassword())
                .role(getRole())
                .build();
    }
}

CustomUserDetails

@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {

    private final MemberInfoDto memberInfoDto;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(Role.USER.toString()));
    }

    @Override
    public String getPassword() {
        return memberInfoDto.getPassword();
    }

    @Override
    public String getUsername() {
        return memberInfoDto.getEmail();
    }
}

MemberInfoDto

@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MemberInfoDto {
    private Long memberId;
    private String email;
    private String password;
    private Role role;
}
  • 다음과 같이 Member의 정보를 가진 MemberInfoDto를 생성하여 이를 CustomUserDetails에서 사용하도록 하였다.

CustomUserDetailsService

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member member = memberRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("Member Not Found"));

        MemberInfoDto memberInfoDto = member.toMemberInfoDto();
        return new CustomUserDetails(memberInfoDto);
    }
}
  • email로 member를 찾아 반환한다. 인증 관련해서는 CustomUserDetails를 사용하므로 member를 MemberInfoDto로 변경해야한다.

JwtInfoDto

  • 해당 클래스는 로그인 시 return할 토큰 정보이다. grantType, accessToken, refreshToken과 각각의 만료 시간을 보여준다.
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class JwtInfoDto {

    private String grantType;

    private String accessToken;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private Date accessTokenExpireTime;

    private String refreshToken;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    private Date refreshTokenExpireTime;
}
  • JsonFormat 애노테이션을 사용하여 Date의 데이터 포맷을 String으로 변경한다.

JwtUtil

  • 해당 클래스는 Jwt관련 토큰을 생성하고, claims을 파싱하고, 유효성을 검사하는 등의 기능을 제공하는 클래스이다.
@Slf4j
@Component
public class JwtUtil {

    private final Key key;
    private final Long accessTokenExpireTime;
    private final Long refreshTokenExpireTime;

    public JwtUtil(@Value("${jwt.secret}") String secret,
                   @Value("${jwt.access_expiration_time}") Long accessTokenExpireTime,
                   @Value("${jwt.refresh_expiration_time}") Long refreshTokenExpireTime) {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.accessTokenExpireTime = accessTokenExpireTime;
        this.refreshTokenExpireTime = refreshTokenExpireTime;
    }

    /**
     * accessToken, refreshToken을 생성한다.
     * 컴포넌트 내의 createAccessToken, createRefreshToken을 호출하여 생성한다.
     * @param memberInfoDto
     * @return JwtInfoDto
     */
    public JwtInfoDto createToken(MemberInfoDto memberInfoDto) {

        Date accessTokenExpirationTime = new Date(currentTimeMillis() + accessTokenExpireTime);
        Date refreshTokenExpirationTime = new Date(currentTimeMillis() + refreshTokenExpireTime);

        String accessToken = createAccessToken(memberInfoDto, accessTokenExpirationTime);
        String refreshToken = createRefreshToken(memberInfoDto, refreshTokenExpirationTime);

        return JwtInfoDto.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .accessTokenExpireTime(accessTokenExpirationTime)
                .refreshToken(refreshToken)
                .refreshTokenExpireTime(refreshTokenExpirationTime)
                .build();
    }

    /**
     * accessToken 생성
     * @param memberInfoDto
     * @param expirationTime
     * @return accessToken
     */
    private String createAccessToken(MemberInfoDto memberInfoDto, Date expirationTime) {
        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject("AccessToken")
                .setIssuedAt(new Date())
                .setExpiration(expirationTime)
                .claim("memberId", memberInfoDto.getMemberId())
                .claim("email", memberInfoDto.getEmail())
                .claim("role", memberInfoDto.getRole())
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    /**
     * refreshToken 생성
     * @param memberInfoDto
     * @param expirationTime
     * @return
     */
    private String createRefreshToken(MemberInfoDto memberInfoDto, Date expirationTime) {
        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject("RefreshToken")
                .setIssuedAt(new Date())
                .setExpiration(expirationTime)
                .claim("memberId", memberInfoDto.getMemberId())
                .claim("email", memberInfoDto.getEmail())
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    /**
     * token을 파싱하여 email을 리턴
     * @param token
     * @return email
     */
    public String getEmail(String token) {
        return parseClaims(token).get("email", String.class);
    }

    /**
     * 해당 token이 유효한지 체크
     * @param token
     * @return
     */
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            log.info("JWT 토큰이 유효하지 않습니다.", e);
        } catch (ExpiredJwtException e) {
            log.info("JWT 토큰이 만료되었습니다.", e);
        } catch (UnsupportedJwtException e) {
            log.info("지원하지 않는 JWT 토큰 입니다.", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims가 비어있습니다.", e);
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}
  • JwtUtil 생성자에서 설정파일에서 바인딩해놓은 secretaccess_expiration_time, refresh_expiration_time을 사용한다. 이때 secret으로 JWT signature에 사용할 비밀 키로 변환한다. 해당 문자열을 BASE64 형식으로 디코딩한 후, Keys.hmacShaKeyFor(keyBytes)를 사용하여 Key 클래스의 비밀 키를 생성한다.
  • createToken : MemberInfoDto를 파라미터로 받아 accessToken, refreshToken을 생성한다.
  • createAccessToken : MemberInfoDto, expirationTime을 파라미터로 받는다.
    .setHeaderParam("typ", "JWT")
    .setSubject("AccessToken")
    .setIssuedAt(new Date())
    .setExpiration(expirationTime)
    • 다음과 같이 등록된 claims를 넣으며,

      .claim("memberId", memberInfoDto.getMemberId())
      .claim("email", memberInfoDto.getEmail())
      .claim("role", memberInfoDto.getRole())
    • 이와 같이 등록되지 않은 claims를 등록한다. 이는 해당 토큰으로 member를 식별하기 위함이다.

      .signWith(key, SignatureAlgorithm.HS256)
    • JwtUtil생성자를 통해 생성한 key로 어떤 알고리즘으로 JWT를 생성했는지 서명을 진행한다.

  • createRefreshToken : accessToken의 생성과 비슷하다. 만료시간을 길게 설정해 주어야 한다.
  • getEmail : toke n으로 email을 추출한다. parseClaims를 사용한다.
  • validateToken : 해당 token이 올바른지 체크한다.
  • parseClaims : token을 파싱하여 등록해놓은 claims들을 가져올 수 있다.

JwtAuthenticationFilter

  • 해당 필터에서 accessToken을 검사하여 유효성 체크 후, 유효하다면 Authentication객체를 생성하여 SecurityContextAuthentication을 넣어 인증을 완료한다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final CustomUserDetailsService customUserDetailsService;
    private final JwtUtil jwtUtil;

    @Override
        protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
            String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

            if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
                String token = authorizationHeader.substring(7);

                if(jwtUtil.validateToken(token)) {
                    String email = jwtUtil.getEmail(token);

                    UserDetails userDetails = customUserDetailsService.loadUserByUsername(email);

                    if(userDetails != null) {
                        Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails.getUsername(), null, userDetails.getAuthorities());

                        SecurityContextHolder.getContext().setAuthentication(authentication);
                    }
                }
            }
            filterChain.doFilter(request, response);
        }
}
  • 인증을 하기 위해서는 클라이언트가 accessTokenAuthorization이라는 이름의 헤더에 Bearer ${accessToken}형식으로 넣어야 한다.
  • OncePerRequestFilter를 extends하여 해당 필터가 한번만 요청되도록 한다.
  • doFilterInternal에서 request를 파싱하여 accessToken을 추출한 후, 유효성 검사를 진행한다.
    Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails.getUsername(), null, userDetails.getAuthorities());
    SecurityContextHolder.getContext().setAuthentication(authentication);
    • 다음과 같이 UsernamePasswordAuthenticaionToken이라는 Authentication 객체를 생성한다.
    • 이를 인증객체를 관리하는 SecurityContext에 넣어 인증을 완료한다.

AuthApiController

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
public class AuthApiController {

    private final AuthService authService;

    @PostMapping("/signup")
    public ResponseEntity<String> signup(@RequestBody SignUpRequestDto signUpRequestDto) {
        authService.signup(signUpRequestDto);
        return ResponseEntity.ok("Sign Up Success!");
    }

    @PostMapping("/login")
    public ResponseEntity<JwtInfoDto> login(@RequestBody LoginRequestDto loginRequestDto) {
        JwtInfoDto jwtInfoDto = authService.login(loginRequestDto);
        return ResponseEntity.ok(jwtInfoDto);
    }
}
  • signup 시, Sign Up Success!를 반환한다.
  • login 시, jwtInfoDto를 반환한다.

AuthService

@Service
@RequiredArgsConstructor
public class AuthService {

    private final PasswordEncoder passwordEncoder;
    private final MemberRepository memberRepository;
    private final JwtUtil jwtUtil;

    public void signup(SignUpRequestDto signUpRequestDto) {
        String encodedPassword = passwordEncoder.encode(signUpRequestDto.getPassword());

        Member member = signUpRequestDto.toEntity(encodedPassword);
        memberRepository.save(member);
    }

    public JwtInfoDto login(LoginRequestDto loginRequestDto) {
        String email = loginRequestDto.getEmail();
        String password = loginRequestDto.getPassword();

        Member member = memberRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("Member Not Found"));

        if(!passwordEncoder.matches(password, member.getPassword())) {
            throw new BadCredentialsException("Not Matches password");
        }

        MemberInfoDto memberInfoDto = member.toMemberInfoDto();
        return jwtUtil.createToken(memberInfoDto);
    }
}
  • signup : 받아온 password를 인코딩하여 member 테이블에 save한다.
  • login :
    • email에 해당하는 member가 없을 때, UsernameNotFoundException을 반환한다.
    • password가 맞지 않을 때, BadCredentialsException를 반환한다.
    • email로 찾은 member로 MemberInfoDto를 생성하여 createToken을 호출한다.

JwtController

  • refreshToken으로 만료된 accessToken을 새로 생성하는 api를 위한 controller이다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/jwt")
public class JwtController {

    private final JwtService jwtService;

    @PostMapping("/reissue")
    public ResponseEntity<AccessTokenDto> reissueAccessToken(HttpServletRequest request) {
        String header = request.getHeader("Authorization");
        String refreshToken = header.substring(7);

        AccessTokenDto accessTokenDto = jwtService.reissueAccessTokenByRefreshToken(refreshToken);

        return ResponseEntity.ok(accessTokenDto);
    }
}
  • refreshToken을 Http body에 넣을지 header에 넣을지 고민을 했는데 accessToken과 같이 Authorization 헤더에 넣기로 결정했다.
  • JwtUtil에서 메서드를 만들어 사용하기보다 JwtService를 생성하였다.
  • AccessTokenDto는 아래와 같다.
    @Getter
    @Builder
    @AllArgsConstructor
    @NoArgsConstructor
    public class AccessTokenDto {
        private String grantType;
        private String accessToken;
    
        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
        private Date accessTokenExpireTime;
    }

JwtService

@Service
@RequiredArgsConstructor
public class JwtService {

    private final MemberService memberService;
    private final JwtUtil jwtUtil;

    public AccessTokenDto reissueAccessTokenByRefreshToken(String refreshToken) {
        Long memberId = jwtUtil.getMemberId(refreshToken);
        Member member = memberService.findByMemberId(memberId);

        MemberInfoDto memberInfoDto = member.toMemberInfoDto();
        Date accessTokenExpireTime = jwtUtil.createAccessTokenExpireTime();

        String accessToken = jwtUtil.createAccessToken(memberInfoDto, accessTokenExpireTime);

        return AccessTokenDto.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .accessTokenExpireTime(accessTokenExpireTime)
                .build();
    }
}
  • MemerServiceJwtUtil을 주입하였다. MemberService를 거치지 않고 MemberRepository를 주입하여 사용하고 싶었으나 repository는 해당 service에서 호출하려고 하였다.
  • refreshToken으로 MemberInfoDto를 가져와 JwtUtilcreateAccessToken을 호출하여 새로운 accessToken을 생성하였다.

SecurityConfig

package spring.auth.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import spring.auth.auth.handler.CustomAccessDeniedHandler;
import spring.auth.auth.handler.CustomAuthenticationEntryPoint;
import spring.auth.auth.service.CustomUserDetailsService;
import spring.auth.enums.Role;
import spring.auth.jwt.JwtAuthenticationFilter;
import spring.auth.jwt.JwtUtil;

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomUserDetailsService userDetailsService;
    private final JwtUtil jwtUtil;
    private final CustomAccessDeniedHandler accessDeniedHandler;
    private final CustomAuthenticationEntryPoint authenticationEntryPoint;

    private static final String[] AUTH_WHITELIST = {
        "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", "/", "/login", "/signup",
        "/api/v1/auth/**", "/swagger-ui/index.html#/", "/api/v1/jwt/reissue"
    };

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        //CSRF, CORS
        http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(Customizer.withDefaults())
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable);

        //세션 관리 상태 없음으로 구성
        http
                .sessionManagement(sessionManagement -> sessionManagement
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

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

        // JwtAuthFilter를 filterChain에 추가
        http
                .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
        
        // permit, authenticated 경로 설정
        http
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers(AUTH_WHITELIST).permitAll() // 지정한 경로는 인증 없이 접근 허용
                        .anyRequest().authenticated()); // 나머지 모든 경로는 인증 필요

        return http.build();
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter(userDetailsService, jwtUtil);
    }
}
  • 전과 달라진 부분은 Spring Security에서 제공하는 Form Login을 disable하였다는 것이다. JWT인증을 사용하기에 직접 POST방식의 /login을 만들어야 했기 때문이다.
  • 또한 기존에 사용한 세션을 사용하지 않으므로 STATELESS로 설정하였다.
  • accessToken을 재발급받기 위한 /api/v1/jwt/reissue를 AUTH_WHITELIST에 추가하였다.
  • 가장 중요한, JwtAuthenticationFilter를 filterChain에 추가하였다.
    http
          .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    • 위와 같이 UsernamePasswordAuthenticationFilter 앞에 해당 필터를 추가함으로써 JWT인증을 수행하도록 설정하였다.
    • JwtAuthenticationFilter에서 userDetailsServicejwtUtil을 주입해야하므로
      @Bean
      public JwtAuthenticationFilter jwtAuthenticationFilter() {
          return new JwtAuthenticationFilter(userDetailsService, jwtUtil);
      }
      위와 같이 빈을 등록해야 한다.

실행

  • 지금까지 JWT인증을 사용한 signup, login, accessToken 재발급을 구현하였다.
  • View가 아닌 API 형식으로 구현하였으므로 Swagger 혹은 Postman으로 실행 가능하다.
  • 먼저 signup부터 진행하였다.

  • 이와 같이 db에 잘 들어갔음을 확인할 수 있다.

  • login을 진행해보자

  • JwtInfoDto가 성공적으로 body에 응답으로 왔다.
    • 해당 요청을 보낸 시간은 7/15 19:58이다. 이것으로 accessToken, refreshToken의 유효기간을 계산할 수 있다.
    • accessToken의 유효시간은 7/15 20:08이다. 즉, 19:58 → 20:08 이므로 accessToken의 유효기간을 10분으로 설정했음을 알 수 있다. (1000(ms) x 60(s) x 10(min) = 600000)
    • refreshToken의 유효시간은 8/14 19:58이다. 7/15 19:58 → 8/14 19:58 이므로 refreshToken의 유효기간을 30일로 설정했음을 알 수 있다. (1000(ms) x 60(s) x 60(min) x 24(hour) x 30(days) = 2592000000)

  • accessToken을 가지고 인증이 필요한 페이지에 접근해보았다.

  • 다음과 같이 accessToken을 Bearer Token에 넣어 보낸 요청이 인증에 성공하여 해당 페이지를 응답으로 받아온 것을 확인할 수 있다.
  • 이제 refreshToken으로 accessToken을 재발급받아보자
    • 10분을 기다린 뒤, accessToken으로 인증이 필요한 페이지에 접근하지 못하는걸 확인하였다.

  • 인증이 실패했을 때 리다이렉트되는 url이 View 형식이었으므로 다음과 같은 html형식이 응답으로 오게 되었다.
  • 이제 refreshToken으로 새로 accessToken을 발급받아 보았다.

  • 해당 accessToken으로 인증이 필요한 페이지에 접근 가능함을 확인할 수 있다.

  • 해당 플로우를 실행하면서 클라이언트는 accessToken이 만료된 상황에서 본인의 refreshToken을 어떻게 알 수 있을까? 라는 의문이 들었다. 열심히 생각해본 결과 다음과 같은 생각으로 귀결되었다.
    • 로그인 후 발급받은 accessTokenrefreshToken을 프론트단에서 로컬 저장소에 저장을 한다. 요청마다 저장소에서 accessToken을 가져와 헤더에 넣어 인증하도록 하며, accessToken이 만료 시 refreshToken을 가져와 refreshToken을 발급받는 api로 요청을 보내 새로운 accessToken을 받아올 수 있다고 생각하였다.
    • Member엔티티에 refreshToken을 담는 생각도 하였지만 결국에 해당 Member를 찾으려면 인증이 필요하므로 로컬에 저장해야 맞는 로직이 될거라고 생각한다.

정리

  • 추가로 accessToken을 가지고 jwt 토큰을 인코딩, 디코딩해주는 jwt.io에서 디코딩을 해보았다.

  • 다음과 같이 Encoded부분에 accessToken을 입력하고 verify signature 부분에 secret string을 작성하면 해당 Signature가 Verified되었다는 메세지를 받을 수 있다.

    • 추가로 HEADER와 PAYLOAD부분의 정보들을 디코딩하여 Spring Boot에서 작성한 claims의 값들을 확인할 수 있었다. 이렇게 JWT토큰을 탈취했을 시 개인정보가 노출될 수 있으므로 비밀번호와 같은 정보는 PAYLOAD에 넣어서는 안된다는 것을 다시 한번 상시키길 수 있었다.
  • 이렇게 Spring Security를 이용하여 세션을 사용한 기본 Form Login부터 JWT인증 방식을 이용한 Form Login까지 구현해보았다.
  • 이렇게 해도 로그인의 기본만 구현했다고 생각한다. 요구사항에 따라 다양한 설정을 붙일 수 있다.
  • Spring Security의 기본부터 차근차근 이해안되는 부분에서는 공식 문서를 읽으며 진행하였다. 처음엔 많이 버거웠지만 충분히 이해하며 구현할 수 있었다.
  • 다음엔 OAuth2 프로토클을 사용한 소셜 로그인을 구현할 계획이다.

0개의 댓글