Java/Spring

[Spring] Spring Security + JWT를 이용한 토큰 발급 및 회원가입, 로그인 로직 구현

SeungbeomKim 2023. 1. 8. 17:59
반응형

JWT란 무엇인가 ?

RFC 7519 웹 표준으로 지정되어 있고, JSON 객체를 사용해서 토큰 자체에 정보들을 저장하고 있는 Json Web Token이다.

 

헤비 하지 않고, 아주 간편하고 쉽게 적용할 수 있어서 사이드 프로젝트를 진행할 때 매우 유용한 방식이다. 몇 가지 고려사항만 잘 대응하면, 대규모 프로젝트에도 충분히 이용 가능한 방식이다.

 

JWT 구성요소

Header : Signature를 해싱하기 위한 알고리즘 정보들이 담겨있음.

Payload : 서버와 클라이언트가 주고받는, 시스템에서 실제로 사용될 정보에 대한 내용들을 담고 있다.

Signature : 토큰이 유효성 검증을 위한 문자열, 이 토큰이 유효한 토큰인지 확인 가능.

 

JWT 장점 

- 중앙의 인증서버, 데이터 스토어에 대한 의존성 없음, 시스템 수평 확장 유리

- 토큰이 클라이언트에 저장, 서버에서 클라이언트의 토큰을 조작할 수 없음

 

Client가 Server 측으로부터 토큰을 발급받는 과정

  1. 클라이언트가 서버에 ID/PW를 입력해 로그인 시도
  2. 서버 측에서 ID/PW가 해당 클라이언트의 ID/PW와 일치한 지 확인 후 Access Token + Refrest Token 발급 후 클라이언트 측으로 전달
  3. 클라이언트 측에서 API 요청 시 Access Token 헤더에 담아서 요청
  4. 서버 측에서 API 응답 혹은 Access Token 만료 응답을 전달
  5. 발급받은 토큰이 만료되면, 서버 측으로 재발급 요청(Access Token + Refresh Token)
  6. 서버 측에서 토큰 검증 후 새로운 Access Token + Refresh Token 발급

추가해줘야 할 디펜던시(build.gradle에 추가 or 프로젝트 생성 시 Spring Security 추가)

 

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

implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

프로젝트 구조

TokenProvider : 토큰의 유효성 검증 및 생성을 위함

@Component
@PropertySource("classpath:jwt.properties")
public class TokenProvider implements InitializingBean {

    private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
    private static final String AUTHORITIES_KEY = "auth";
    private final String secret;
    private final long tokenValidityInMilliseconds;
    private Key key;


    public TokenProvider(
            @Value("${jwt.secret}") String secret,
            @Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
        this.secret = secret;
        this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
    }

    @Override
    public void afterPropertiesSet() {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }
    // bean이 생성되고 주입을 받은 후에 secret값을 Base64 Decode해서 key 변수에 할당

    public String createToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();
        Date validity = new Date(now + this.tokenValidityInMilliseconds);
        //만료 시간 설정

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS512)
                .setExpiration(validity)
                .compact();
    }
    // Authentication 객체의 권한정보를 이용해서 토큰을 생성하는 createToken 메소드 추가


    public Authentication getAuthentication(String token) {
        Claims claims = Jwts
                .parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        User principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }
    // Token에 담겨있는 정보를 이용해 Authentication 객체를 리턴하는 메소드 생성
    // 토큰으로 클레임을 만들고 이를 이용해 유저 객체를 만들어서 최종적으로 Authentication 객체를 리턴
    // 권한정보를 이용 유저객체를 만들어주고, 유저정보, 토큰, 권한정보를 이용해 Authentication 객체를 리턴

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            logger.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            logger.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            logger.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            logger.info("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
    // 토큰의 유효성 검증, 토큰을 파싱해보고 발생하는 예외 처리, 문제 있으면 false, 정상이면 true
}

우선적으로, Token의 시크릿 키 값과 만료시간을 application.properties에 따로 설정해줘야 한다.

TokenProvider 클래스에서는 authentication 객체의 권한정보를 이용해 토큰을 생성하는 메소드, 생성된 토큰, 권한정보, 객체를 이용하여 Authentication 객체를 리턴하는 메소드, 토큰의 유효성을 검증하는 메소드가 담겨있다.

 

JwtFilter 

public class JwtFilter extends GenericFilterBean {

    private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
    public static final String AUTHORIZATION_HEADER = "Authorization";
    private TokenProvider tokenProvider;
    public JwtFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String jwt = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();

        if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
        } else {
            logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);

        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        return null;
    }
}

GenericFilterBean을 extends해서 doFilter Override, 실제 필터링 로직은 doFilter 내부에 작성

doFilter(): JWT Token 인증정보를 SecurityContext 저장하는 역할 수행

resolveToken(): Request Header에서 토큰 정보를 꺼내오기 위한 메소드

resolveToken을 통해 토큰을 받아와서 유효성 검증을 하고, 정상 토큰이면 SecurityContext에 저장

 

JwtSecurityConfig

public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    private TokenProvider tokenProvider;
    public JwtSecurityConfig(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void configure(HttpSecurity http) {
        http.addFilterBefore(
                new JwtFilter(tokenProvider),
                UsernamePasswordAuthenticationFilter.class
        );
    }
}

TokenProvider, JwtFilter를 SecurityConfig에 적용할 때 사용하기 위한 클래스

SecurityConfigurerAdpater를 extends하고 TokenProvider를 주입받아서 JwtFilter를 통해 Security로직에 필터를 등록

 

JwtAuthenticationEntryPoint

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // 유효한 자격증명을 제공하지 않고 접근하려 할때 401
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

사용자 정보가 잘못되거나, 토큰이 유효하지 않은 경우에 대비하기 위한 클래스이다.(401 Unauthorized)

 

JwtAccessDeniedHandler

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
        //필요한 권한이 없이 접근하려 할때 403
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

필요한 권한이 존재하지 않은 경우, 403 Forbidden 에러를 리턴하기 위한 클래스이다.

예시를 들어, 유저, 관리자, 매니저 역할이 있을 때, 유저는 유저 권한만 가져야 하고, 매니저는 유저, 매니저, 관리자는 모든 권한을 가져야 한다. 유저가 관리자 권한에 접근할 수 없기에, 이를 대비하기 위한 클래스로 볼 수 있다.

 

CorsConfig

@Configuration
public class CorsConfig {
    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");

        source.registerCorsConfiguration("/api/**", config);
        return new CorsFilter(source);
    }
}

 

프론트엔드, 백엔드를 구분지어서 개발하거나, 서로 다른 Server 환경에서 자원을 공유할 때,

Cors설정이 안 되어있으면 오류가 발생한다. 이를 방지하기 위해 Cors 설정을 해주어야 한다.

 

SecurityConfig

 

Spring Security 설정 파일, SecurityConfig TokenProvider, JwtAuthenticationEntryPoint, JwtAccessDeniedHandler 주입

@EnableMethodSecurity
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
    private final TokenProvider tokenProvider;
    private final CorsFilter corsFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

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


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                // token을 사용하는 방식이기 때문에 csrf를 disable합니다.
                .csrf().disable()

                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)

                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler)

                // enable h2-console
                .and()
                .headers()
                .frameOptions()
                .sameOrigin()

                // 세션을 사용하지 않기 때문에 STATELESS로 설정
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeHttpRequests()
                .requestMatchers("/api/hello", "/api/authenticate", "/api/signup").permitAll()
                .requestMatchers(PathRequest.toH2Console()).permitAll()
                .anyRequest().authenticated()

                //로그인, 회원가입 API는 토큰이 없는 상태에서 요청이 들어오기 때문에, 모두 permitAll 설정

                .and()
                .apply(new JwtSecurityConfig(tokenProvider));
                // JwtFilter를 addFilterBefore로 등록했던 JwtSecurityConfig 클래스도 적용

        return httpSecurity.build();
    }
}

 

addFilterBefore() : 필터를 등록한다. 스프링 시큐리티 필터링에 등록해주어야 하기 때문에, 여기에 등록해주어야 한다. 매개변수는 2가지가 들어간다. 왼쪽은 커스텀한 필터링(corsFilter)이 들어간다. 오른쪽에 등록한 필터 전에 커스텀 필터링(usernamePasswordAuthenticataionFilter)이 수행된다.

requestMatcher() : 해당 URL 요청이 들어오면, 설정을 해준다.

authenticated() : 인증 필요

사용자 데이터를 세션에 저장하지 않고, Json 객체에 담기 때문에 세션을 STATELESS로 설정한다 (비활성화)

 

Jwt 패키지 이외에도, 외부와의 통신을 사용하기 위한 DTO패키지를 따로 만들어주었고, Repository도 따로 만들어주었습니다.

 

 

 

CustomUserDetailsService 

@Component("userDetailsService")
@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;


    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;

    }

    @Override
    @Transactional
    public UserDetails loadUserByUsername(final String username) {
        return userRepository.findOneWithAuthoritiesByUsername(username)
                .map(user -> createUser(username, user))
                .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
    }

    private org.springframework.security.core.userdetails.User createUser(String username, User user) {
        if (!user.isActivated()) {
            throw new RuntimeException(username + " -> 활성화되어 있지 않습니다.");
        }

        List<GrantedAuthority> grantedAuthorities = user.getAuthorities().stream()
                .map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
                .collect(Collectors.toList());

        return new org.springframework.security.core.userdetails.User(user.getUsername(),
                user.getPassword(),
                grantedAuthorities);
    }
}

UserDetailsService를 implements 하고 UserRepository를 주입받는다. 

loadUserByUsername 메소드를 오버라이드해서 로그인시 DB에서 유저정보와 권한정보를 가져오게 된다.

해당 정보를 기반으로 userDetails User객체를 생성하여 리턴한다.

 

로그인 API

AuthController

@RestController
@RequestMapping("/api")
public class AuthController {
    private final TokenProvider tokenProvider;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;

    public AuthController(TokenProvider tokenProvider, AuthenticationManagerBuilder authenticationManagerBuilder) {
        this.tokenProvider = tokenProvider;
        this.authenticationManagerBuilder = authenticationManagerBuilder;
    }

    @PostMapping("/authenticate")
    public ResponseEntity<TokenDto> authorize(@Valid @RequestBody LoginDto loginDto) {

        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());

        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        SecurityContextHolder.getContext().setAuthentication(authentication);

        String jwt = tokenProvider.createToken(authentication);

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);

        return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
    }
}

TokenProvider, AuthenticationManagerBuilder를 주입받음

로그인 API 경로 : /api/authenticate -> Post요청

LoginDto의 username, password를 파라미터로 받고, 이를 이용해 UsernamePasswordAuthenticationToken을 생성한다.

 

authentication Token을 이용해서 Authentication 객체를 생성하려고 authenticate 메소드가 실행될때, loadUserByUsername 메소드가 실행된다.

JWT Token Response 헤더에도 넣어주고, TokenDto 이용해서 Response Body에도 넣어서 리턴하게 된다.

 

회원가입 API, 권한검증 확인

 

SecurityUtil

public class SecurityUtil {

    private static final Logger logger = LoggerFactory.getLogger(SecurityUtil.class);

    private SecurityUtil() {}

    public static Optional<String> getCurrentUsername() {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null) {
            logger.debug("Security Context에 인증 정보가 없습니다.");
            return Optional.empty();
        }

        String username = null;
        if (authentication.getPrincipal() instanceof UserDetails) {
            UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
            username = springSecurityUser.getUsername();
        } else if (authentication.getPrincipal() instanceof String) {
            username = (String) authentication.getPrincipal();
        }

        return Optional.ofNullable(username);
    }
}

getCurrentUsername() : Security Context의 Authentication 객체를 이용해 username을 리턴해주는 간단한 유틸성 메소드

Security Context Authentication객체가 저장되는 시점은 JwtFilter doFilter메소드에서 Request가 들어올 때이고, SecurityContext Authentication 객체를 저장해서 사용하게

 

UserService

@Service
public class UserService {
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @Transactional
    public UserDto signup(UserDto userDto) {
        if (userRepository.findOneWithAuthoritiesByUsername(userDto.getUsername()).orElse(null) != null) {
            throw new DuplicateMemberException("이미 가입되어 있는 유저입니다.");
        }

        Authority authority = Authority.builder()
                .authorityName("ROLE_USER")
                .build();

        User user = User.builder()
                .username(userDto.getUsername())
                .password(passwordEncoder.encode(userDto.getPassword()))
                .nickname(userDto.getNickname())
                .authorities(Collections.singleton(authority))
                .activated(true)
                .build();

        return UserDto.from(userRepository.save(user));
    }

    @Transactional(readOnly = true)
    public UserDto getUserWithAuthorities(String username) {
        return UserDto.from(userRepository.findOneWithAuthoritiesByUsername(username).orElse(null));
    }

    @Transactional(readOnly = true)
    public UserDto getMyUserWithAuthorities() {
        return UserDto.from(
                SecurityUtil.getCurrentUsername()
                        .flatMap(userRepository::findOneWithAuthoritiesByUsername)
                        .orElseThrow(() -> new NotFoundMemberException("Member not found"))
        );
    }
}

UserRepository, PasswordEncoder를 주입받는다.

signup() : 회원가입 로직

username이 DB에 존재하지 않으면, Authority, User 정보를 생성해서 UserRepository의 save 메소드를 통해 DB에 정보를 저장함.

 

유저 권한 정보를 가져오는 메소드

getUserWithAuthorities -> username을 기준으로 정보를 가져옴

getMyUserWithAuthorities -> SecurityContext 저장된 username 정보만 가져온다.

 

UserController

@RestController
@RequestMapping("/api")
public class UserController {
    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/hello")
    public ResponseEntity<String> hello() {
        return ResponseEntity.ok("hello");
    }

    @PostMapping("/test-redirect")
    public void testRedirect(HttpServletResponse response) throws IOException {
        response.sendRedirect("/api/user");
    }

    @PostMapping("/signup")
    public ResponseEntity<UserDto> signup(
            @Valid @RequestBody UserDto userDto
    ) {
        return ResponseEntity.ok(userService.signup(userDto));
    }

    @GetMapping("/user")
    @PreAuthorize("hasAnyRole('USER','ADMIN')")
    public ResponseEntity<UserDto> getMyUserInfo(HttpServletRequest request) {
        return ResponseEntity.ok(userService.getMyUserWithAuthorities());
    }

    @GetMapping("/user/{username}")
    @PreAuthorize("hasAnyRole('ADMIN')")
    public ResponseEntity<UserDto> getUserInfo(@PathVariable String username) {
        return ResponseEntity.ok(userService.getUserWithAuthorities(username));
    }
}

 signup() : UserDto를 매개변수로 받아서 UserSerivce signup 메소드 호출

getMyUserInfo() : @PreAuthorize를 통해 User, Admin 두 가지 권한 모두 허용

getUserInfo(): admin 권한만 호출할 수 있도록 설정

userService에서 만들었던 username 파라미터를 기준으로 유저 정보와 권한 정보를 리턴하는 API가 됨

 

<참고 자료>

Spring boot JWT Tutorial 정은구님 강의영상 및 자료 참조

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt/dashboard

 

[무료] Spring Boot JWT Tutorial - 인프런 | 강의

Spring Boot, Spring Security, JWT를 이용한 튜토리얼을 통해 인증과 인가에 대한 기초 지식을 쉽고 빠르게 학습할 수 있습니다., - 강의 소개 | 인프런...

www.inflearn.com

https://velog.io/@jkijki12/Spirng-Security-Jwt-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0

 

Spirng Security + Jwt 로그인 적용하기

프로젝트를 진행하면서 Spring Security + Jwt를 이용한 로그인을 구현하게 되었다. 목차 Spring Security JWT Spring SEcurity + JWT Spring Security > 가장먼저 스프링 시큐리티에 대해서 알아보자. Sprin

velog.io

https://bcp0109.tistory.com/301

 

Spring Security 와 JWT 겉핥기

Introduction 이 글에서는 Spring Boot + JWT + Security 를 사용해서 회원가입/로그인 로직을 구현했습니다. JWT 와 Spring Security 코드는 인프런 Spring Boot JWT Tutorial (정은구) 강의를 수강하면서 만들고 제 스타

bcp0109.tistory.com

 

반응형