넷플릭스 클론 코딩을 진행하며 JWT로그인을 시도한 기록입니다.
java: 17
springBoot: 3.1.5
springSecurity: 6.1.2
jjwt: 0.12.3
gradle: 8.3
1. 사용자가 서비스에 로그인 요청을 보내면 제공된 사용자 이름과 비밀번호를 사용해UsernamePasswordAuthenticationToken
이라는 인증 객체가 생성됩니다.
2. 사용자의 이름이나 비밀번호가 올바르지 않으면 예외가 발생하고 HTTP 4xx 응답이 사용자에게 반환 됩니다.
3. 인증에 성공하면 데이터베이스에서 사용자를 검색하고 사용자가 존재하지 않으면 HTTP 4xx 응답이 사용자에게 전송됩니다.
4. 사용자 정보가 확보되면 JwtUtils를 호출하여 JWT를 생성하고 JSON 응답 내부에서 캡슐화되어 사용자에게 반환 됩니다
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
https://github.com/Get-bot/netflixCloneServer
@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;
}
}
@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;
}
@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;
}
}
public enum ERole {
ROLE_USER,
ROLE_MODERATOR,
ROLE_ADMIN
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Boolean existsByEmail(String email);
}
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {
Optional<Role> findByName(ERole name);
}
@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가 필요합니다. 지정하지 않으면 일반 텍스트를 사용합니다.
애플리케이션에서 인증 및 권한 부여를 위해 Spring Security를 사용하는 경우, 사용자별 데이터를 Spring Security API에 제공하고 인증 프로세스 중에 사용해야 합니다. 이 사용자별 데이터는 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 보안 및 인증 객체로 작업하는 것이 중요합니다.
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 객체를 빌드합니다.
@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() 내부에서 수행하는 작업:
email
을 구문 분석합니다.email
에서 UserDetails
를 가져와 인증 객체를 만듭니다.setAuthentication(authentication)
메서드를 사용하여 SecurityContext에서 현재 UserDetails
를 설정합니다.@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;
}
}
이제 AuthenticationEntryPoint 인터페이스를 구현하는 AuthEntryPointJwt 클래스를 생성합니다. 그런 다음 commence() 메서드를 재정의합니다. 이 메서드는 인증되지 않은 사용자가 보안 HTTP 리소스를 요청하고 AuthenticationException이 발생할 때마다 트리거됩니다.
@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);
}
}
@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);
}
}
@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.";
}
}