본문 바로가기

기록하기

Day + 39


Spring Security, Jwt 이용한 로그인, 회원가입 구현하기

  • 2주 챌린지 프로젝트팀에서 로그인, 회원가입 기능의 구현을 담당해서, 세부적인 학습의 필요성을 느낌

Spring Security

  • Spring에서 인증(Authentication)과 인가(Authorization) 기능을 지원하는 보안 프레임워크
  • Spring MVC 기반 애플리케이션에 보안을 적용하기 위한 표준
  • Interceptor나 Servlet Filter를 이용해서 직접 Security를 구현할 필요 X
  • 직접 구현 X -> Spring Security 이용

JWT

  • 세션 기반 인증 방식은 사용자의 로그인 정보를 서버 측에서 관리하기 때문에, 서버에 부하가 발생할 수 있음
  • REST API를 이용한 CSR 방식의 백엔드 서버를 개발할 것이기 때문에 무상태성을 유지하기 위해서는 세션 기반 인증 방식은 적절하지 X
  • JWT -> 무상태성을 유지하면서 인증된 사용자의 자격을 증명 O
  • 사용자가 누구인지 기억할 필요 X -> 토큰에 있는 정보에 접근 권한이 있는지만 체크
  • But, 토큰이 탈취되면 사용자 정보를 그대로 노출 -> 민감한 정보는 토큰에 포함 X
  • Access Token, Refresh Token 두 가지의 토큰으로 나누어 Access Token의 유효 기간을 짧게 -> 만료되면 Refresh Token을 통해 Access Token을 새로 발급하는 방식으로 안정성을 높임 (하지만 이 방식도 완벽하지 X) 

Login

  1. 클라이언트에서 사용자의 ID, Password를 받아 서버에 로그인 요청
  2. 서버는 전달받은 ID, Password를 가진 User 객체를 조회 -> 존재 O -> Access Token, Refresh Token 생성
  3. 생성한 Access Token, Refresh Token을 DB 저장 후 -> HttpServletResponse Header에 담아 전달
  4. 클라이언트는 발급받은 Access Token을 HttpServletResponse Header에 담아 서버가 허용한 권한 범위 내에서 API 사용 가능

JwtTokenizer : JWT를 생성하고 검증

@Slf4j
@Component
public class JwtTokenProvider {
    public static final String BEARER_TYPE = "Bearer";
    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String REFRESH_HEADER = "Refresh";
    public static final String BEARER_PREFIX = "Bearer ";

    @Getter
    @Value("${jwt.secret-key}")
    private String secretKey;

    @Getter
    @Value("${jwt.access-token-expiration-millis}")
    private long accessTokenExpirationMillis;

    @Getter
    @Value("${jwt.refresh-token-expiration-millis}")
    private long refreshTokenExpirationMillis;
    private Key key;

    // Bean 등록후 Key SecretKey HS256 decode
    @PostConstruct
    public void init() {
        String base64EncodedSecretKey = encodeBase64SecretKey(this.secretKey);
        this.key = getKeyFromBase64EncodedKey(base64EncodedSecretKey);
    }

    public String encodeBase64SecretKey(String secretKey) {
        return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public TokenDto generateTokenDto(CustomUserDetails customUserDetails) {
        Date accessTokenExpiresIn = getTokenExpiration(accessTokenExpirationMillis);
        Date refreshTokenExpiresIn = getTokenExpiration(refreshTokenExpirationMillis);
        Map<String, Object> claims = new HashMap<>();
        claims.put("role", customUserDetails.getRole());

        String accessToken = Jwts.builder()
                .setClaims(claims)
                .setSubject(customUserDetails.getEmail())
                .setExpiration(accessTokenExpiresIn)
                .setIssuedAt(Calendar.getInstance().getTime())
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        String refreshToken = Jwts.builder()
                .setSubject(customUserDetails.getEmail())
                .setIssuedAt(Calendar.getInstance().getTime())
                .setExpiration(refreshTokenExpiresIn)
                .signWith(key)
                .compact();

        return TokenDto.builder()
                .grantType(BEARER_TYPE)
                .authorizationType(AUTHORIZATION_HEADER)
                .accessToken(accessToken)
                .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                .refreshToken(refreshToken)
                .build();
    }

    // JWT 토큰을 복호화하여 토큰 정보를 반환
    public Authentication getAuthentication(String accessToken) {
        Claims claims = parseClaims(accessToken);

        if (claims.get("role") == null) {
            throw new BusinessLogicException(ExceptionCode.NO_ACCESS_TOKEN);
        }

        String authority = claims.get("role").toString();

        CustomUserDetails customUserDetails = CustomUserDetails.of(
                claims.getSubject(),
                authority);

        log.info("# AuthMember.getRoles 권한 체크 = {}", customUserDetails.getAuthorities().toString());

        return new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
    }

    // 토큰 검증
    public boolean validateToken(String token, HttpServletResponse response) {
        try {
            parseClaims(token);
        } catch (MalformedJwtException e) {
            log.info("Invalid JWT token");
            log.trace("Invalid JWT token trace = {}", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT token");
            log.trace("Expired JWT token trace = {}", e);
            Responder.sendErrorResponse(response, ExceptionCode.TOKEN_EXPIRED);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT token");
            log.trace("Unsupported JWT token trace = {}", e);
            Responder.sendErrorResponse(response, ExceptionCode.TOKEN_UNSUPPORTED);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.");
            log.trace("JWT claims string is empty trace = {}", e);
            Responder.sendErrorResponse(response, ExceptionCode.TOKEN_ILLEGAL_ARGUMENT);
        }
        return true;
    }

    private Date getTokenExpiration(long expirationMillisecond) {
        Date date = new Date();

        return new Date(date.getTime() + expirationMillisecond);
    }

    // Token 복호화 및 예외 발생(토큰 만료, 시그니처 오류)시 Claims 객체가 안만들어짐.
    public Claims parseClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    public void accessTokenSetHeader(String accessToken, HttpServletResponse response) {
        String headerValue = BEARER_PREFIX + accessToken;
        response.setHeader(AUTHORIZATION_HEADER, headerValue);
    }

    public void refresshTokenSetHeader(String refreshToken, HttpServletResponse response) {
        response.setHeader("Refresh", refreshToken);
    }

    // Request Header에 Access Token 정보를 추출하는 메서드
    public String resolveAccessToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }

    // Request Header에 Refresh Token 정보를 추출하는 메서드
    public String resolveRefreshToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(REFRESH_HEADER);
        if (StringUtils.hasText(bearerToken)) {
            return bearerToken;
        }
        return null;
    }
}

 

  • JWT 생성 시 들어갈 정보
    • setClaims() : JWT에 포함시킬 Custom Claims를 추가. 주로 인증된 사용자의 정보를 넣음
    • setSubject() : JWT에 대한 제목
    • setIssuedAt() : JWT 발행 일자 (파라미터 타입은 java.util.Date)
    • setExpiration() : JWT의 만료기한을 정함 (파라미터 타입은 java.util.Date)
    • signWith() : 서명을 위한 Key (java.security.Key) 객체를 설정
    • compact() : JWT를 생성하고 직렬화
  • getKeyFromBase64EncoedeKey : JWT 서명에 사용될 SecretKey를 생성. Decoders.BASE64.decode() 메서드를 통해 byte[]를 반환한 후, Keys.hmacShaKeyFor() 메서드로 HMAC 알고리즘을 적용한 Key 객체를 생성.
  • validateToken : JWT에 포함된 Signature를 검증하여 위조 여부를 확인. 검증에 성공하면 JWT를 파싱 해서 Claims를 얻어온다
    • parseClaims() : JWT를 파싱해서 Claims를 얻어온다.
  • generateTokenDto : CustomUserDetials의 유저 정보를 기반으로 Access Token과 Refresh Token을 생성하는 메서드

TokenDto 

@Data
@Builder
public class TokenDto {
    private final String grantType;
    private final String authorizationType;
    private final String accessToken;
    private final String refreshToken;
    private final Long accessTokenExpiresIn;
}

JwtAuthenticationFilter

  • JwtAuthenticationFilter 클래스는 로그인 검증 및 JWT 발급을 담당
  • JwtAutheticationFilter가 상속받고 있는 UsernamePasswordAuthenticationFilter는 Spring Security에서 사용자 이름과 암호를 받아 인증을 시도하는 필터
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;
    private final AES128Config aes128Config;
    private final MemberService memberService;
    private final RedisService redisService;

    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        // ServletInputStream을 LoginDto 객체로 역직렬화
				ObjectMapper objectMapper = new ObjectMapper();
        LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());

        return authenticationManager.authenticate(authenticationToken);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws IOException, ServletException {
        CustomUserDetails customUserDetails = (CustomUserDetails) authResult.getPrincipal();
        TokenDto tokenDto = jwtTokenProvider.generateTokenDto(customUserDetails);
        String accessToken = tokenDto.getAccessToken();
        String refreshToken = tokenDto.getRefreshToken();
        String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
        jwtTokenProvider.accessTokenSetHeader(accessToken, response);
        jwtTokenProvider.refresshTokenSetHeader(encryptedRefreshToken, response);
        Member findMember = memberService.findMemberAndCheckMemberExists(customUserDetails.getId());
        Responder.loginSuccessResponse(response, findMember);

        // 로그인 성공시 Refresh Token Redis 저장 ( key = Email / value = Refresh Token )
        long refreshTokenExpirationMillis = jwtTokenProvider.getRefreshTokenExpirationMillis();
        redisService.setValues(findMember.getEmail(), refreshToken, Duration.ofMillis(refreshTokenExpirationMillis));

        this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
    }
}

 

  • attemptAuthentication()
    • 인증을 시도하는 메서드
    • HttpServletRequest와 HttpServletResponse를 매개변수로 받아 사용자가 입력한 로그인 정보를 추출하고 AuthenticationManager를 사용해 인증을 시도
    • 인증에 성공하면 Authentication 객체를 반환하고 인증이 실패하면 AuthenticationException을 throw
    • 위 코드에서는 @SneakyThrows 애너테이션으로 발생한 예외를 throw
  • successfulAuthentication()
    • 사용자 인증이 성공했을 때 호출되는 메서드
    • 인증 성공 후에 응답을 한다거나, 인증 정보를 저장하는 등의 추가 작업을 수행 O
    • 위 코드에서는 로그인을 성공했다는 정보를 응답해 주고 생성한 Email과 RefreshToken을 key -value 값으로 Redis에 저장

CustomUserDetailService

  • UserDetailsService를 구현한 클래스
  • UserDetailsService 인터페이스는 Spring Security에서 인증 정보를 조회하기 위해 사용
@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return memberRepository.findByEmail(email)
                .map(this::createUserDetails)
                .orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND));
    }

    private UserDetails createUserDetails(Member member) {
        return CustomUserDetails.of(member);

 

  • loadByUsername()
    • 사용자 이름(Email)을 입력받아 MemberRepository에서 사용자 정보를 조회
    • 조회한 Member 객체가 존재하면 createUserDetails() 메서드를 사용해서 CustomUserDetails 객체를 생성하고 반환

CustomUserDetails

  • UserDetails 인터페이스를 구현한 클래스
  • Spring Security에서 관리하는 User 정보를 관리
@Getter
@NoArgsConstructor
@ToString
public class CustomUserDetails extends Member implements UserDetails {
    private Long id;
    private String email;
    private String role;
    private String password;

    private CustomUserDetails(Member member) {
        this.id = member.getId();
        this.email = member.getEmail();
        this.password = member.getPassword();
        this.role = member.getRole();
    }

    private CustomUserDetails(String email, String role) {
        this.email = email;
        this.role = role;
    }

    private CustomUserDetails(String email, String password, String role) {
        this.email = email;
        this.password = password;
        this.role = role;
    }

    public static CustomUserDetails of(Member member) {
        return new CustomUserDetails(member);
    }

    public static CustomUserDetails of(String email, String role) {
        return new CustomUserDetails(email, role);
    }

    public static CustomUserDetails of(String email, String password, String role) {
        return new CustomUserDetails(email, password, role);
    }

    @Override
    public List<GrantedAuthority> getAuthorities() {
        return CustomAuthorityUtils.createAuthorities(role);
    }

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

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

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

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

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

 

  • getAuthorities()
    • 권한을 생성하고 List<GrantedAuthority> 타입으로 반환
  • isAccountNonExpired() , isAccountNonLocked(), isCredentialsNonExpired(), isEnabled()
    • 사용자 계정이 만료되지 않았는지, 잠금 상태인지, 인증 정보가 만료되지 않았는지, 활성화 상태인지 여부를 반환하는 메서드
    • 모두 true를 반환하도록 구현

CustomAuthorityUtils

  • 권한 정보를 생성하고 검증하는 유틸리티 클래스
@Slf4j
public class CustomAuthorityUtils {
    public static List<GrantedAuthority> createAuthorities(String role) {
        return List.of(new SimpleGrantedAuthority("ROLE_" + role));
    }

    public static void verifiedRole(String role) {
        if (role == null) {
            throw new BusinessLogicException(ExceptionCode.MEMBER_ROLE_DOES_NOT_EXISTS);
        } else if (!role.equals(USER.toString()) && !role.equals(ADMIN.toString())) {
            throw new BusinessLogicException(ExceptionCode.MEMBER_ROLE_INVALID);
        }
    }
}

 

  • createAuthorities()
    • 입력된 Role 값을 기반으로 권한 정보를 생성하여 List<GrantedAuthority> 타입으로 변환
    • 권한 정보는 "ROLE_USER", "ROLE_ADMIN" 형식으로 생성
  • verifiedRole()
    • 입력된 Role 값이 유효한 권한인지 검증

SecurityConfiguration

  • Spring Cecurity 설정을 담당하는 클래스
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            .headers().frameOptions().sameOrigin() 
            .and() 
            .csrf().disable()
            .cors().configurationSource(corsConfigurationSource()) 
            .and() 
            .formLogin().disable()
            .httpBasic().disable()
            .sessionManagement().sessionCreationPolicy(STATELESS) 
            .and() 
            .exceptionHandling() 
            .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
            .accessDeniedHandler(new CustomAccessDeniedHandler())
            .and() 
            .apply(new CustomFilterConfigurer()) 
            .and() 
            // TODO: 추후 권한 별 페이지 접근제어 설정 예정
            .authorizeHttpRequests(authorize -> authorize
                    .anyRequest().permitAll());

    return http.build();
}

 

  • headers().frameOptions().sameOrigin() : X-Frame-Options 헤더 설정을 SAMEORIGIN으로 설정하여, 웹 페이지를 iframe으로 삽입하는 공격 방지를 위한 설정
  • http.csrf().disable() : jwt를 사용하기 때문에 CSRF(Cross-Site Request Forgery) 공격 방지 기능을 사용하지 X
  • http.cors().configurationSource(corsConfigurationSource()) : CORS(Cross-Origin Resource Sharing)를 활성화하고, 허용할 origin, method, header 등을 설정한 corsConfigurationSource() 메서드를 지정
  • http.formLogin().disable() : 폼 기반 로그인 방식을 비활성화
  • http.httpBasic().disable() : HTTP 기본 인증 방식을 비활성화
  • http.sessionManagement().sessionCreationPolicy(STATELESS) : 인증에 사용할 세션을 생성하지 않도록 설정
  • http.exceptionHandling() : 예외 처리를 설정
  • authenticationEntryPoint(new CustomAuthenticationEntryPoint()) : 인증되지 않은 사용자가 보호된 리소스에 접근할 때 호출할 엔드포인트를 설정
  • accessDeniedHandler(new CustomAccessDeniedHandler()) : 인가되지 않은 사용자가 보호된 리소스에 접근할 때 호출할 핸들러를 설정
  • apply(new CustomFilterConfigurer()) : 사용자 정의 필터를 적용
  • authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()) : 모든 HTTP 요청에 대해 접근을 허용
  • corsConfigurationSource() : CORS를 설정하는 메서드

@Bean
CorsConfigurationSource corsConfigurationSource() {
   CorsConfiguration configuration = new CorsConfiguration();
   configuration.setAllowedOrigins(List.of("*"));
   configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE"));
   configuration.setAllowCredentials(true);
   configuration.addExposedHeader("Authorization");
   configuration.addExposedHeader("Refresh");
   configuration.addAllowedHeader("*");
   configuration.setMaxAge(3600L);

   UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
   source.registerCorsConfiguration("/**", configuration);
   return source;
}

 

  • setAllowedOrigins(List.of("*")) : 모든 Origin에서 접근이 가능하도록 해놨다. 이후 Origin 이 확정되면 변경해줘야 한다.
  • setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE")) : HTTP 요청 메서드 중 GET, POST, PATCH, DELETE 만 허용
  • setAllowCredentials(true) : true로 설정하면 Access-Control-Allow-Credentials 헤더가 설정된다.
  • addExposedHeader() : 클라이언트에게 노출할 헤더 값을 설정
  • addAllowedHeader() : 클라이언트가 전송할 수 있는 헤더 값을 설정
  • setMaxAge() : 클라이언트가 다시 preflight 요청을 보내지 않아도 되는 시간을 설정
  • registerCorsConfiguration("/**", configuration) : CORS 구성을 등록, “/**” 는 모든 경로에서 CORS가 적용되도록 설정한다.
  • CustomFilterConfigurer 클래스는 인증 및 권한 부여를 위해 사용되는 필터 체인을 구성하는 데 사용된다. AbstractHttpConfigurer 를 상속하며 configure() 메서드를 재정의한다

public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
    @Override
    public void configure(HttpSecurity builder) throws Exception {
        log.info("SecurityConfiguration.CustomFilterConfigurer.configure excute");
        AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class);
        JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager,
                jwtTokenProvider, aes128Config, memberService, redisService);
        JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenProvider, redisService);

        jwtAuthenticationFilter.setFilterProcessesUrl("/auth/login");
        jwtAuthenticationFilter.setAuthenticationSuccessHandler(new LoginSuccessHandler());
        jwtAuthenticationFilter.setAuthenticationFailureHandler(new LoginFailurHandler());

        builder
                .addFilter(jwtAuthenticationFilter)
                .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);
    }
}

 

  • setFilterProcessesUrl("/auth/login") : 로그인 URL을 설정
  • setAuthenticationSuccessHandler() , setAuthenticationFailureHandler() : 로그인 성공 및 실패 시 호출되는 핸들러를 설정
  • addFitler() 메서드를 사용하여 필터를 추가하고, addFilterAfter() 메서드를 사용하여 JwtAuthenticationFilter 다음에 JwtVerificationFilter 를 추가한다.

참조 https://green-bin.tistory.com/68

 

Spring - Spring Security + JWT 적용기 1편: 로그인

Spring Security란? Spring Security는 Spring에서 인증(Authentication)과 인가(Authorization) 기능을 지원하는 보안 프레임워크로써, Spring MVC 기반 애플리케이션에 보안을 적용하기 위한 표준이다. Spring Security 덕

green-bin.tistory.com

 

'기록하기' 카테고리의 다른 글

Day + 48  (1) 2023.12.02
Day + 42  (0) 2023.11.26
Docker  (0) 2023.11.21
Day + 37  (0) 2023.11.21
Day + 36  (0) 2023.11.20