JWT + Spring Security

p-q·2023년 11월 14일
1
post-thumbnail

넷플릭스 클론

넷플릭스 클론 코딩을 진행하며 JWT로그인을 시도한 기록입니다.

1. Application Architecture

1. 개발환경

java: 17
springBoot: 3.1.5
springSecurity: 6.1.2
jjwt: 0.12.3
gradle: 8.3

2. 아키텍쳐

시나리오

  • 사용자가 서비스 측에 계정 생성을 요청합니다.
  • 사용자가 서비스에 계정 인증 요청을 제출합니다.
  • 인증된 사용자가 리소스 액세스 요청을 보냅니다.

Sign Up

  1. 사용자가 서비스에 가입요청을 제출하면 요청 데이터에서 사용자 객체가 생성됩니다.
  2. userService가 호출되어 사용자 객체 유효성 검사와 JPA를 활용해 사용자가 데이터베이스에 저장 됩니다.
  3. JwtUtils가 호출되어 사용자 객체에 대한 JWT를 반환합니다
  4. JWT는 JSON 응답 내부에서 캡슐화되어 사용자에게 반환됩니다.

Sign In


1. 사용자가 서비스에 로그인 요청을 보내면 제공된 사용자 이름과 비밀번호를 사용해UsernamePasswordAuthenticationToken이라는 인증 객체가 생성됩니다.
2. 사용자의 이름이나 비밀번호가 올바르지 않으면 예외가 발생하고 HTTP 4xx 응답이 사용자에게 반환 됩니다.
3. 인증에 성공하면 데이터베이스에서 사용자를 검색하고 사용자가 존재하지 않으면 HTTP 4xx 응답이 사용자에게 전송됩니다.
4. 사용자 정보가 확보되면 JwtUtils를 호출하여 JWT를 생성하고 JSON 응답 내부에서 캡슐화되어 사용자에게 반환 됩니다

resources test

2. Gradle 설정

    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'

3. 소스코드

GitHub

https://github.com/Get-bot/netflixCloneServer

1.Entity 구현

userEntity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
@Table(name = "users")
public class User {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String email;

  private String username;

  private String password;

  @CreatedDate
  private LocalDateTime createdAt;

  @OneToMany(mappedBy = "user", orphanRemoval = true, cascade = CascadeType.ALL)
  private Set<UserRoles> roles = new HashSet<>();

  private Integer status;

  public static User registerUser(String email, String username, String password, Set<Role> roles) {
    User user = new User();
    user.email = email;
    user.username = username;
    user.password = password;
    user.status = UserState.ACTIVE.getValue();

    for (Role role : roles) {
      UserRoles userRoles = UserRoles.createUserRoles(user, role);
      user.getRoles().add(userRoles);
    }

    return user;
  }

}

RoleEntity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "roles")
public class Role {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Enumerated(EnumType.STRING)
  private ERole name;

  private String description;

}

UserRolesJuntionEntity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "user_roles")
public class UserRoles {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "user_id")
  private User user;

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "role_id")
  private Role role;


  public static UserRoles createUserRoles(User user, Role role) {
    UserRoles userRoles = new UserRoles();
    userRoles.user = user;
    userRoles.role = role;
    return userRoles;
  }

}

ERole

public enum ERole {
  ROLE_USER,
  ROLE_MODERATOR,
  ROLE_ADMIN
}

2. 저장소구현

UserRepository

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
  Optional<User> findByEmail(String email);
  Boolean existsByEmail(String email);
}

RoleRepository

@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {
  Optional<Role> findByName(ERole name);
}

3. 스프링 보안 구성

WebSecurityConfig

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@AllArgsConstructor
public class WebSecurityConfig {

  private final UserDetailsServiceImpl userDetailsService;

  private final AuthEntryPointJwt unauthorizedHandler;

  @Bean
  public AuthTokenFilter authenticationJwtTokenFilter() {
    return new AuthTokenFilter();
  }

  @Bean
  public DaoAuthenticationProvider authenticationProvider() {
    DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
    authProvider.setUserDetailsService(userDetailsService);
    authProvider.setPasswordEncoder(passwordEncoder());
    return authProvider;
  }

  @Bean
  public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
    return authConfig.getAuthenticationManager();
  }

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

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(AbstractHttpConfigurer::disable)
        .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
        .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth ->
            auth.requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/test/**").permitAll()
                .anyRequest().authenticated()
        );

    http.authenticationProvider(authenticationProvider());

    http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);

    return http.build();
  }
}
  • @EnableWebSecurity 주석은 Spring 보안의 웹 보안 지원을 활성화합니다. 이것은 Spring Security가 웹 기반 보안을 제공하도록 설정하는 것을 의미합니다. 이 주석이 적용된 설정 클래스는 웹 보안을 위한 다양한 보안 규칙과 구성을 정의합니다. 예를 들어, URL 패턴에 따른 접근 제어, 로그인 페이지의 구성, 세션 관리 등이 이에 해당합니다. @EnableWebSecurity를 사용하면 Spring Security가 이러한 설정을 찾아서 전체 애플리케이션의 글로벌 웹 보안 설정으로 자동 적용합니다.

  • @EnableMethodSecurity 주석은 메소드 수준의 보안을 활성화합니다. 이 주석은 클래스 또는 메소드 수준에서 보안 정책을 세밀하게 정의할 수 있게 해 줍니다. 예를 들어, 특정 역할을 가진 사용자만이 특정 메소드를 호출할 수 있도록 제한하거나, 메소드 실행 전 후에 특정 보안 규칙을 적용할 수 있습니다. @PreAuthorize, @PostAuthorize, @Secured 등의 주석을 메소드에 적용하여 이러한 보안 정책을 구현할 수 있습니다

  • WebSecurityConfigurerAdapter 인터페이스에서 configure(HttpSecurity http) 메서드를 재정의합니다. 이 메서드는 모든 사용자에게 인증을 요구할지 여부, 모든 사용자에게 인증을 요구할지 여부, 어떤 필터(AuthTokenFilter)를 사용할지 여부, 언제 작동할지 여부(UsernamePasswordAuthenticationFilter 앞에 필터링), 어떤 예외 처리기(AuthEntryPointJwt)를 선택할지 여부 등을 Spring Security에 알려줍니다.

  • Spring Security는 인증 및 권한 부여를 수행하기 위해 사용자 세부 정보를 로드합니다. 따라서 구현해야 하는 UserDetailsService 인터페이스가 있습니다.

  • UserDetailsService의 구현은 AuthenticationManagerBuilder.userDetailsService() 메서드에 의해 DaoAuthenticationProvider를 구성하는 데 사용됩니다.

  • 또한 DaoAuthenticationProvider에 대한 PasswordEncoder가 필요합니다. 지정하지 않으면 일반 텍스트를 사용합니다.

4. userDetails 구현

애플리케이션에서 인증 및 권한 부여를 위해 Spring Security를 사용하는 경우, 사용자별 데이터를 Spring Security API에 제공하고 인증 프로세스 중에 사용해야 합니다. 이 사용자별 데이터는 UserDetails 객체에 캡슐화됩니다. UserDetails는 다양한 메서드를 포함하는 인터페이스입니다.

자세한 정보 : UserDetails

userDetails

인증 프로세스가 성공하면 인증 객체에서 사용자 이름, 비밀번호, 권한과 같은 사용자 정보를 가져올 수 있습니다.

public class UserDetailsImpl implements UserDetails {
  private static final long serialVersionUID = 1L;

  private final Long id;

  private final String email;

  private final String username;

  @JsonIgnore // 이 어노테이션은 JSON으로 변환될 때, password를 제외시킴
  private final String password;

  private final Collection<? extends GrantedAuthority> authorities;

  public UserDetailsImpl(Long id, String username, String email, String password,
                         Collection<? extends GrantedAuthority> authorities) {
    this.id = id;
    this.email = email;
    this.username = username;
    this.password = password;
    this.authorities = authorities;
  }

  public static UserDetailsImpl build(User user) {
    List<GrantedAuthority> authorities = user.getRoles().stream()
        .map(role -> new SimpleGrantedAuthority(role.getRole().getName().name()))
        .collect(Collectors.toList());

    return new UserDetailsImpl(
        user.getId(),
        user.getUsername(),
        user.getEmail(),
        user.getPassword(),
        authorities);
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return authorities;
  }

  public Long getId() {
    return id;
  }

  public String getEmail() {
    return email;
  }

  @Override
  public String getPassword() {
    return password;
  }

  @Override
  public String getUsername() {
    return username;
  }

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

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

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

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

  @Override
  public boolean equals(Object o) {
    if (this == o)
      return true;
    if (o == null || getClass() != o.getClass())
      return false;
    UserDetailsImpl user = (UserDetailsImpl) o;
    return Objects.equals(id, user.id);
  }

}

위의 코드를 보면 Set<Role>List<GrantedAuthority>로 변환한 것을 알 수 있습니다. 나중에 사용할 Spring 보안 및 인증 객체로 작업하는 것이 중요합니다.

userDetailsImpl

loadUserByUsername() 메서드를 재정의합니다.

@Service
@AllArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

  private final UserRepository userRepository;

  @Override
  @Transactional(readOnly = true)
  public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
    User user = userRepository.findByEmail(email)
        .orElseThrow(() -> new UsernameNotFoundException("회원의 이메일을 찾을 수 없습니다: " + email));
    return UserDetailsImpl.build(user);
  }
}

UserRepository를 사용하여 전체 사용자 정의 User 객체를 가져온 다음 정적 build() 메서드를 사용하여 UserDetails 객체를 빌드합니다.

5. 요청 필터링

AuthTokenFilter

@RequiredArgsConstructor
public class AuthTokenFilter extends OncePerRequestFilter {

  private static final Logger logger = Logger.getLogger(AuthTokenFilter.class.getName());
  private final JwtUtils jwtUtils;
  private final UserDetailsServiceImpl userDetailsService;

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    try {
      String jwt = parseJwt(request);
      if (jwt != null && jwtUtils.vaildateJwtToken(jwt)) {
        String email = jwtUtils.getUserNameFromJwtToken(jwt);

        var userDetails = userDetailsService.loadUserByUsername(email);
        UsernamePasswordAuthenticationToken authentication =
            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

        SecurityContextHolder.getContext().setAuthentication(authentication);
      }
    } catch (Exception e) {
      logger.severe("Cannot set user authentication: " + e);
    }

    filterChain.doFilter(request, response);
  }

  private String parseJwt(HttpServletRequest request) {
    String headerAuth = request.getHeader("Authorization");

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

    return null;
  }
}

doFilterInternal() 내부에서 수행하는 작업:

  • Authorization 헤더에서 JWT를 가져옵니다(Bearer 접두사를 제거).
  • 요청에 JWT가 있으면 유효성을 검사하고 email을 구문 분석합니다.
  • email에서 UserDetails를 가져와 인증 객체를 만듭니다.
  • setAuthentication(authentication) 메서드를 사용하여 SecurityContext에서 현재 UserDetails를 설정합니다.

6. JWT 유틸리티 클래스 생성

  • 사용자 이름, 날짜, 만료일, 비밀번호에서 JWT 생성하기
  • JWT에서 사용자 이름 가져오기
  • JWT 유효성 검사
@Component
public class JwtUtils {
  private static final Logger logger = LoggerFactory.getLogger(JwtUtils.class);

  private final SecretKey key;
  @Value("${netflixclone.app.jwtExpirationMs}")
  private int jwtExpirationMs;

  public JwtUtils(@Value("${netflixclone.app.jwtSecret}") String jwtSecret) {
    this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSecret));
  }

  public String generateJwtToken(Authentication authentication) {

    UserDetailsImpl userPrincipal = (UserDetailsImpl) authentication.getPrincipal();

    return Jwts.builder()
        .subject(userPrincipal.getEmail())
        .claim("roles", userPrincipal.getAuthorities())
        .issuedAt(new Date())
        .expiration(new Date((new Date()).getTime() + jwtExpirationMs))
        .signWith(key)
        .compact();
  }

  public String getUserNameFromJwtToken(String token) {
    return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().getSubject();
  }

  public boolean vaildateJwtToken(String token) {
    try {
      Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
      return true;
    } catch (MalformedJwtException e) {
      logger.error("Invalid JWT token: {}", e.getMessage());
    } catch (ExpiredJwtException e) {
      logger.error("JWT token is expired: {}", e.getMessage());
    } catch (UnsupportedJwtException e) {
      logger.error("JWT token is unsupported: {}", e.getMessage());
    } catch (IllegalArgumentException e) {
      logger.error("JWT claims string is empty: {}", e.getMessage());
    }
    return false;
  }
  
}

7. 인증 예외처리

이제 AuthenticationEntryPoint 인터페이스를 구현하는 AuthEntryPointJwt 클래스를 생성합니다. 그런 다음 commence() 메서드를 재정의합니다. 이 메서드는 인증되지 않은 사용자가 보안 HTTP 리소스를 요청하고 AuthenticationException이 발생할 때마다 트리거됩니다.

AuthEntryPointJwt

@Component
public class AuthEntryPointJwt implements AuthenticationEntryPoint {
  private static final Logger logger = LoggerFactory.getLogger(AuthEntryPointJwt.class);

  @Override
  public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
      throws IOException, ServletException {
    logger.error("Unauthorized error: {}", authException.getMessage());
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

    final Map<String, Object> body = new HashMap<>();
    body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
    body.put("error", "Unauthorized");
    body.put("message", authException.getMessage());
    body.put("path", request.getServletPath());

    final ObjectMapper mapper = new ObjectMapper();
    mapper.writeValue(response.getOutputStream(), body);
  }
}

8. Spring RestAPI 컨트롤러 생성

AuthController

@CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
  private final AuthenticationManager authenticationManager;

  private final UserRepository userRepository;

  private final UserService userService;

  private final JwtUtils jwtUtils;

  @PostMapping("/signin")
  public ResponseEntity<?> authenticateUser(@RequestBody LoginRequest loginRequest) {
    return ResponseEntity.ok().body(authenticateAndGenerateJWT(loginRequest.getEmail(), loginRequest.getPassword()));
  }

  @PostMapping("/signup")
  public ResponseEntity<?> registerAndAuthenticateUser(@RequestBody SignupRequest signupRequest) throws CustomException {

    // 유저 등록
    userService.registerUser(signupRequest);

    JwtResponse jwtResponse = authenticateAndGenerateJWT(signupRequest.getEmail(), signupRequest.getPassword());
    ApiResponse<JwtResponse> response = ApiResponse.setApiResponse(true, "회원 가입이 완료 되었습니다!", jwtResponse);

    return ResponseEntity.ok().body(response);
  }

  private JwtResponse authenticateAndGenerateJWT(String email, String password) {
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(email, password));
    SecurityContextHolder.getContext().setAuthentication(authentication);

    String jwt = jwtUtils.generateJwtToken(authentication);
    UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
    List<String> roleNames = userDetails.getAuthorities().stream()
        .map(GrantedAuthority::getAuthority)
        .toList();

    return JwtResponse.setJwtResponse(jwt, userDetails.getId(), userDetails.getUsername(), userDetails.getEmail(), roleNames);
  }

}

ApiController

@CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("/api/test")
public class ApiController {

  @GetMapping("/all")
  public String allAccess() {
    return "Public Content.";
  }

  @GetMapping("/user")
  @PreAuthorize("hasRole('USER') or hasRole('MODERATOR') or hasRole('ADMIN')")
  public String userAccess() {
    return "User Content.";
  }

  @GetMapping("/mod")
  @PreAuthorize("hasRole('MODERATOR')")
  public String moderatorAccess() {
    return "Moderator Board.";
  }

  @GetMapping("/admin")
  @PreAuthorize("hasRole('ADMIN')")
  public String adminAccess() {
    return "Admin Board.";
  }

}

4. 실행 테스트 해보기

Sign in

success

fail

Test

success

fail

profile
ppppqqqq

0개의 댓글