Spring Security + JWT 를 적용한 회원가입/로그인 구현

Seunghee Lee·2024년 3월 14일

캡스톤

목록 보기
5/10
post-thumbnail

회원가입과 로그인을 구현하기 위해 JWTSpring Security 에 대해 알아보았다. 이제 개념을 얼추 이해했으니 본격적으로 구현을 해보자.

✓ ENV.

개발 환경

  • IntelliJ
  • Java 17.0.2
  • SpringBoot 3.2.1

Spring 프로젝트 생성
📌 Spring 프로젝트 생성 사이트로 들어가서 아래 툴을 Dependencies 에 추가한다.

  • JPA
  • Web
  • H2
  • Lombok
  • Security
  • Validation

✓ CODE

패키지 구조

본인은 아래와 같은 패키지 구조로 진행했다.

SecurityConfig.java

📍 Config 와 관련하여 추가적인 설정을 위해서 다음과 같은 방법이 있다.
1. WebSecurityConfigurer 를 implements 하는 방법 ✅
2. WebSecurityConfigurerAdapter 를 extends 하는 방법

여기서 1번 방법을 채택해 구현하였다.

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)    // @PreAuthorize 메소드 적용을 위함
public class SecurityConfig {

    private final TokenProvider tokenProvider;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    public SecurityConfig(
            final TokenProvider tokenProvider,
            final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
            final JwtAccessDeniedHandler jwtAccessDeniedHandler
    ) {
        this.tokenProvider = tokenProvider;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // token 을 사용하기 때문에 csrf 를 disable 한다.
                .csrf(AbstractHttpConfigurer::disable)

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

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

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

                // H2 설정
                .headers(headers ->
                    headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
                )

                // JwtSecurityConfig 적용
                .with(new JwtSecurityConfig(tokenProvider), customizer -> {});

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
  • @EnableWebSecurity : 기본적인 Web 보안을 활성화하겠다.
  • authorizeRequests : HttpServletRequest 를 사용하는 요청들에 대한 접근제한을 설정하겠다.
  • .antMatchers(<API..>).permitAll() : 해당 API 에 대한 요청은 인증없이 접근을 허용하겠다.
  • .anyRequest().authenticated() : 그 외 요청은 모두 인증되어야 한다.

Entity

  • @Getter @Setter
  • @NoArgsConstructor
  • @AllArgsConstructor Get / Set / Builder / Constructor 관련 코드를 자동으로 주입

✔︎ 실무에서는 위 설정들을 잘 고려할 필요가 있다 !
지금은 연습용이기 때문에 편리함을 위해 모두 넣었지만 실무나 어떤 프로젝트를 할 때에는 팀원과 의논하여 설정해야 한다.

✔︎ 특정 문자열에 대한 base64 인코딩 값 도출하기
문자열은 아래 내용이 아니어도 된다. 자신이 원하는 문자열을 넣어서 base64 인코딩 값을 도출하자.


build.gradle

JWT 의존성을 추가하자.

dependencies {
	...
	
	// JWT
	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
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}") final String secret,
            @Value("${jwt.token-validity-in-seconds}") final long tokenValidityInMilliseconds
    ){
        this.secret = secret;
        this.tokenValidityInMilliseconds = tokenValidityInMilliseconds;
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

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

        final long now = (new Date()).getTime();

        final Date validity = new Date(now + this.tokenValidityInMilliseconds);

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS256)
                .setExpiration(validity)
                .compact();
    }

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

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

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

        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    public boolean validateToken(final String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException ex) {
            logger.info("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException ex) {
            logger.info("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException ex) {
            logger.info("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException ex) {
            logger.info("JWT 토큰이 잘못되었습니다.");
        }

        return false;
    }
}

UserRepository.java

public interface UserRepository extends JpaRepository<User, Long> {

    @EntityGraph(attributePaths = "authorities")
    Optional<User> findOneWithAuthoritiesByUsername(final String username);
}
  • @EntityGraph 쿼리가 수행될 때 Eager 조회로 authorities 정보를 같이 가져오게 된다.

CustomUserDetailService.java

Spring Security 에서 중요한 부분 중 하나인 UserDetailService 를 커스텀 구현한 CustomUserDetailService 클래스를 생성하도록 하자.

 @Configuration("userDetailsService")
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

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

    /**
     * 로그인 시 DB 에서 유저정보와 권한정보를 가져와 User 객체 생성
     */
    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        return userRepository.findOneWithAuthoritiesByUsername(username)
                .map(user -> createUser(username, user))
                .orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스를 찾을 수 없습니다."));
    }

    private User createUser(final String username, final leeseunghee.study.jwttutorial.entity.User user) {

        if (!user.isActivated()) {
            throw new RuntimeException(username + "-> 활성화되어 있지 않습니다.");
        }

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

        return new User(user.getUsername(), user.getPassword(), grantedAuthorities);    // User 객체 생성
    }
}

AuthController.java

@RestController
@RequestMapping("/api")
public class AuthController {

    private final TokenProvider tokenProvider;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;

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

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

        final UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginDto.username(), loginDto.password());

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

        final String jwt = tokenProvider.createToken(authentication);

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

        return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
    }
}
  • final Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
    • authenticationToken 을 이용해서 Authentication 객체를 생성하려고 .authenticate 가 실행될 때, CustomUserDetailService 의 loadUserByUsername 메소드 실행
  • 생성된 Authentication 객체를 SecurityContext 에 저장한다.
  • tokenProvider.createToken(authentication)
    • createToken 메소드를 통해 Jwt token 생성
  • Jwt Token 을 Response Header 에 넣어주고, Dto Response Body 에도 넣어서 리턴한다.

SecurityUtil.java

유틸리티 메소드를 만들기 위해 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) {
            final UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();

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

        return Optional.ofNullable(username);
    }
}
  • final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    - Authentication 객체를 꺼내서 username 을 리턴해주는 간단한 유틸성 메소드
    - 이때 Authentication 객체가 저장되는 시점은 JwtFilter 의 doFilter 에서 request 가 들어올 때 저장된다.

실행 결과

테스트 환경
Postman 으로 진행합니다.

회원가입
아래와 같이 JSON 형태로 사용자의 정보를 바디에 담아 전달한다.

로그인
로그인 시 사용자 인증을 거쳐 응답값으로 토큰이 발급된다.

profile
자라나라 개발개발 ~..₩

0개의 댓글