숨밈
들숨에 건강을 날숨에 재력을
숨밈
전체 방문자
오늘
어제
  • 분류 전체보기 (55)
    • 💻 프로젝트 (8)
      • 🍝 홍잇 (5)
      • 🏕 캠퍼 (3)
    • 👩‍💻 개발 (30)
      • ⚙️ BACKEND (16)
      • 🖥 FRONTEND (3)
      • 📡 DEVOPS (7)
      • 💡SOFTWARE (4)
    • 📑 개발 이론 (13)
      • 🚎 JAVA (1)
      • 🌱 SPRING (12)
    • 📚 CS (2)
      • 🔎 Infra (2)
    • 📔 회고 (2)

블로그 메뉴

  • 홈
  • 태그
  • 글쓰기

인기 글

태그

  • Django
  • django-rest-auth
  • 자바스크립트
  • querydsl
  • static final
  • notion
  • 스프링
  • Tistory
  • django-rest-auth_custom
  • jsp
  • 스프링부트
  • django-auth
  • springboot
  • 프리코스
  • 타임리프

티스토리

hELLO · Designed By 정상우.
숨밈

들숨에 건강을 날숨에 재력을

[Campper] SpringSecurity + JWT + 로컬로그인 인증 구현하기
💻 프로젝트/🏕 캠퍼

[Campper] SpringSecurity + JWT + 로컬로그인 인증 구현하기

2023. 10. 9. 21:20

SpringSecurity

SecurityFilterChain은 수많은 필터로 이루어져 있다.

로컬 로그인의 인증 구현을 위해 전반적인 인증 로직 및 UsernamePasswordAuthenticationFilter을 자세히 보면 된다.

Authenticaiotn Flow 

전반적인 인증 흐름은 위와 같다.

  1. 로그인 요청
  2. 로그인 인증 요청이면 UsernamePasswordAuthenticationFilter가 요청을 가로 채 id, pw를 이용한 UsernamePasswordAuthenticationToken인증 객체를 만든다.
  3. AuthenticationManager에 인증을 위임한다.
  4. AuthenticaitonManager을 구현한 ProvicerManager은 시큐리티에서 기본적으로 제공하는 AuthenticationProvider에 인증객체를 전달한다.
  5. AuthenticationProvider에서 UserDetailService에 인증객체를 전달해 주면 해당 객체의 정보들로 DB에서 일치하는 사용자 정보를 찾아 UserDetails 객체를 생성한다.
  6. AuthticationProvicer은 UserDetails와 UsernamePasswordAuthenticationToken비교하여 인증에 성공하면 ProviderManager에 권한을 담은 새로운 UsernamePasswordAuthenticationToken을 생성한다.
  7. UsernamePasswordAuthenticationFilter는 검증된 인증 객체를 SecurityContextHolder의 SecurityContext에 저장한다.

- 참고사항

 

SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors().configurationSource(corsConfigurationSource())
                .and()
                .csrf().disable()
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler);

        http.sessionManagement()
        		/**세션 방식 해제*/
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .httpBasic().disable()
                .authorizeRequests()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                .antMatchers("/admin/**").hasAuthority("ROLE_ADMIN")
                .antMatchers("/**","/login").permitAll()
                /**인증 된 사용자만 사용 가능*/
                .anyRequest().authenticated();

        http.headers()
                .frameOptions().disable();

		/**시큐리티 로그아웃 해제 */
        http.logout().disable();

		/**jwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter보다 먼저 실행*/
        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
	...

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

 

Jwt로 인증을 진행할 것 이므로 Security의 기본 설정인 Session방식을 해제시키고 jwt filter을 우선순위로 올려준다.

.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

 

로그아웃도 세션방식이 아닌 자체적으로 구현 할 것 이므로 disable 시킨다.

http.logout().disable();

 

@Bean
public BCryptPasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

패스워드 암호화를 위한 PasswordEncoder 를 빈으로 등록해야 된다. 스프링부트 2.6 이상부터 WebSecurityConfig 에 빈을 등록할 경우 커스터마이징한 AuthenticationProvider 와 순환 참조 에러가 발생하므로 가급적 다른 Config 클래스에 빈을 추가하는 것이 좋다.

 


로컬로그인

@Override
public GetJwtDto login(PostLoginDto postLoginDto) {
	// 1. Login ID/PW를 기반으로 Authentication 객체 생성
    // 인증을 위한 객체로 아직 권한이 없는 상태 : isAuthentication = false
    UsernamePasswordAuthenticationToken authentication = UsernamePasswordAuthenticationToken(postLoginDto.getAuthKey, postLoginDto.getPwd);

	// 2. 실제 검증 (사용자 비밀번호 체크)이 실행되는 부분
    // authenticate 매서드가 실행 될 때 (Custom)UserDetailService 의 loadUserByUsername 매서드 실행
    authenticationManagerBuilder.getObject().authenticate(authentication);

	// 3. 인증 정보 기반으로 JWT 토큰 생성
    final JwtToken jwtToken = jwtTokenProvider.generateToken(postLoginDto.getAuthKey());

	// RefreshToken Redis 저장
    redisUtil.set("RT:" + jwtToken.getJwtRefreshToken(), postLoginDto.getAuthKey(), 1000L * 60 * 60 * 24 * 14);

    return GetJwtDto.builder()
            .accessToken(jwtToken.getJwtAccessToken())
            .refreshToken(jwtToken.getJwtRefreshToken())
            .build();
}
  1. Login 요청으로 들어온 ID, PW 기반으로  인증을 위한 Authentication 객체를 생성한다.
  2.  authenticate 메서드를 통해 요청된 사용자에 대한 실제 검증이 진행된다.
  3. 검증이 오류없이 완료 되었다면 Login 요청으로 들어온 ID를 기반으로 JWT 토큰을 생성한다.

authenticate 메서드 흐름

  1. 실제 인증을 위해 authenticate() 메서드가 실행 | 해당 메서드 → AuthenticationManager interface 를 구현한 ProviderManager class 메서드임
  2. ProviderManager의 authenticate() 메서드는 모든 provider 중에서 해당 인증을 처리할 수 있는 provider를 찾아, 실제 인증 절차인 authenticate 메서드 실행함
  3. 이때 인증을 처리할 수 있는 provider는 AbstractUserDetailsAuthenticationProvider이고 authenticate() 메서드 로직 중 일부는 DaoAuthenticationProvider에 실제로 구현되어 사용함
  4. AbstractUserDetailsAuthenticationProvider의 authenticate() 메서드를 통해 로그인 요청된 id, pw에 대한 인증 처리
더보기

왜 authentication 객체로 JWT 토큰을 생성하지 않나?

- authentication 과 같이 인증 정보가 담긴 것 보단 노출되기 쉬운 ID값을 담는 것이 더 낫다고 판단했기 때문

@Service
@RequiredArgsConstructor
public class CustomUserDetailServiceImpl implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByAuthKey(username)
                .orElseThrow(() -> new UnauthorizedException(ErrorCode.INVALID_TOKEN));
        return new CustomUserDetails(user,Collections.singleton(new SimpleGrantedAuthority(user.getRole().getViewName())));
    }
}

JWT

JwtAuthenticationFilter

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        try {
            String token = parseBearerToken(request);
            if (token != null && jwtUtil.validateToken(token)) {
                Authentication authentication = jwtUtil.getAuthentication(token);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception e) {
            log.error("Could not set user authentication in security context", e);
            setResponse(response, ErrorCode.USER_NOT_FOUND);
        }
        chain.doFilter(request, response);
    }

    /**
     * Request Header 에서 토큰 정보 추출
     */
    private String parseBearerToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return bearerToken;
    }

 

JwtUtil

@Component
@RequiredArgsConstructor
public class JwtUtil {
    @Value("${JWT_SECRET_KEY}")
    private String secretKey;
    @Value("${AUTHORITIES_KEY}")
    private String AUTHORITIES_KEY;

    private final CustomUserDetailServiceImpl customUserDetailService;

    public Key getSecretKey() {
        return Keys.hmacShaKeyFor(secretKey.getBytes());
    }

    /**토큰 유효성 확인*/

    /**
     * JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
     */
    public Authentication getAuthentication(String token) {
        /** 토큰 복호화*/
        /** 사용자 속성값*/
        Claims claims = parseClaims(token);

        if (claims.get(AUTHORITIES_KEY) == null) {
            throw new UnauthorizedException(ErrorCode.INVALID_AUTH_TOKEN);
        }

        /**userDetails 반환*/
        CustomUserDetails userDetails = (CustomUserDetails) customUserDetailService.loadUserByUsername(claims.getSubject());

        return new UsernamePasswordAuthenticationToken(userDetails.getUser(), null,
                userDetails.getAuthorities());
    }

    public Long getExpiration(String token) {
        Date dueTime = Jwts.parserBuilder().setSigningKey(getSecretKey()).build()
                .parseClaimsJws(token)
                .getBody().getExpiration();

        Long now = new Date().getTime();

        return dueTime.getTime() - now;
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(getSecretKey()).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }

    private Claims parseClaims(String token) {
        try {
            return Jwts.parserBuilder().setSigningKey(getSecretKey()).build()
                    .parseClaimsJws(token)
                    .getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

 

JwtTokenProvider

@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
    @Value("${JWT_SECRET_KEY}")
    private String secretKey;
    @Value("${AUTHORITIES_KEY}")
    private String AUTHORITIES_KEY;

    /**
     * 토큰 유효 시간
     * 60분, 2주
     */
    private static final long JWT_EXPIRATION_TIME = 1000L * 60 * 60;
    private static final long REFRESH_TOKEN_EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 14;


    public Key getSecretKey() {
        return Keys.hmacShaKeyFor(secretKey.getBytes());
    }

    /**
     * 토큰 생성
     */
    public JwtToken generateToken(String authKey)
            throws HttpServerErrorException.InternalServerError {
        final Date now = new Date();
        return JwtToken.builder()
                .jwtAccessToken(generateAccessToken(authKey, now))
                .jwtRefreshToken(generateRefreshToken(now))
                .build();
    }

    public String generateAccessToken(String authKey, Date now) {
        final Date accessTokenExpire = new Date(now.getTime() + JWT_EXPIRATION_TIME);

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer("Campper")
                .setIssuedAt(now) /**생성 일자*/
                .setSubject(authKey)
                .claim(AUTHORITIES_KEY, authKey) /**권한 설정*/
                .setExpiration(accessTokenExpire) /**만료 일자*/
                .signWith(getSecretKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public String generateRefreshToken(Date now) {
        final Date refreshTokenExpire = new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION_TIME);

        return Jwts.builder()
                .setHeaderParam(Header.TYPE, Header.JWT_TYPE)
                .setIssuer("Campper")
                .setExpiration(refreshTokenExpire)
                .signWith(getSecretKey(), SignatureAlgorithm.HS256)
                .compact();
    }

}

https://wildeveloperetrain.tistory.com/57

 

spring security + JWT 로그인 기능 파헤치기 - 1

로그인 기능은 거의 대부분의 애플리케이션에서 기본적으로 사용됩니다. 추가로 요즘은 웹이 아닌 모바일에서도 사용 가능하다는 장점과 Stateless 한 서버 구현을 위해 JWT를 사용하는 경우를 많

wildeveloperetrain.tistory.com

 

저작자표시 (새창열림)

'💻 프로젝트 > 🏕 캠퍼' 카테고리의 다른 글

[Campper] Redis template 사용하여 용량 개선하기  (1) 2023.10.15
[Campper] Spring Cloud Feign 사용하여 공공데이터 쉽게 관리하기  (0) 2023.10.09
    '💻 프로젝트/🏕 캠퍼' 카테고리의 다른 글
    • [Campper] Redis template 사용하여 용량 개선하기
    • [Campper] Spring Cloud Feign 사용하여 공공데이터 쉽게 관리하기
    숨밈
    숨밈
    기술블로그

    티스토리툴바