Spring 3.x Security Jwt 적용하기 2/3

이원찬·2024년 8월 2일
0

Spring

목록 보기
13/13

이 글은 스프링 3.x.x 에서 동작하는 시큐리티 설정입니다.
이전 포스팅 를 먼저 봐주시면 감사하겠습니다.

인증을 적용할 User 엔티티를 작성해 보자

User.java

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private String userId;

    private String email;
    private String password;

    @Enumerated(EnumType.STRING)
    private Role role;
}

public enum Role {
    USER, ADMIN,
}

간단하게 userId, email, password, role 정도만 담고 있다.


로그인 로직에서 중요한건 유저가 작성한 password를 그대로 데이터베이스에 넣으면 안된다는 것이다.

비밀번호는 단방향 해시함수로 한번 감싸서 데이터 베이스에 넣은뒤 사용자가 입력한 비밀번호를 똑같은 해쉬함수로 돌려서 일치하는지 확인해야 한다.

따라서 여기서 사용될 비밀번호 해쉬 함수 ( 여기선 PasswordEncoder 라고 말한다. ) 를 Bean으로 등록해야 한다.

필터를 정의한 SecurityConfig 파일에 가서 PasswordEncoder 를 등록하자

// SecurityConfig.java

import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Bean
public static PasswordEncoder passwordEncoder() {
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

다양한 Encoder 들이 있지만 시큐리티가 제공하는 기본 encoder를 사용한다.

encorder가 잘 동작하는지 확인해 보자!

일단 User가 저장되어야 하니 UserRepository를 만들자

UserRepository.java

// UserRepository.java

@Repository
public interface UserRepository extends JpaRepository<User, String> {
		// 나중에 사용되니 먼저 구현 ^^
    Optional<User> findByEmail(String email);
}
// SecurityPrac3ApplicationTests 대충 테스트 코드 에서

@Autowired
private UserRepository userRepository;

@Autowired
private PasswordEncoder passwordEncoder;

@Test
void contextLoads() {
    User newUser = User.builder()
            .email("test1")
            .password(passwordEncoder.encode("test1"))
            .role(Role.USER)
            .build();

    userRepository.save(newUser);

    System.out.println("newUser.getPassword() = " + newUser.getPassword());
}

>> newUser.getPassword() = {bcrypt}$2a$10$nPUcNr/K0Hl/a4sSRavv.OpPzI6ABZP1jM.nCWICkM.ht0s.TNqAu

잘 저장되는 걸 확인 가능하다

나중에 회원가입 로직에서 이 PasswordEncoder를 요긴하게 사용하면 될것이다.


UserDetails , UserDetailsService, AuthenticationManager, AuthenticationProvider 는 구현을 미룹니다.

다양한 Security 적용 코드에서 위 네가지 인터페이스를 구현하고 Bean으로 등록하는데 이번 구현단계에서는 간단하게 구현하기 위해서 위 4가지 인터페이스 구현을 미룹니다.

(AuthenticationManager, AuthenticationProvider 같은 경우 회원가입 로직은 없어도 되지만 로그인 로직에서는 있는게 편하다!)

다시 User 엔티티로 돌아와서 UserDetails 를 구현하지 않기 때문에 User에 사용하는 메서드 하나만 추가해주자

public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private String userId;

    private String email;
    private String password;

    @Enumerated(EnumType.STRING)
    private Role role;
		
		
		// 이부분! 역할에 따라 권한을 구분할것이기 때문에 필요하다!
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority(this.getRole().name()));
    }
}

만약 역할이 여러개인 User가 있다면 Role 부분을 Collection 로 선언했어야 했을것이다!

Jwt 관련 로직을 담당하는 JwtService와 impl 구현

JwtService 인터페이스

public interface JwtService {
    String extractToken(HttpServletRequest request);

    String extractEmail(String token);

    // 람다 함수를 사용하여 토큰에서 클레임을 추출
    // ex) 토큰에서 userId 추출
    // extractClaim(jwt, Claims::getSubject)
    <T> T extractClaim(String token, Function<Claims, T> claimsResolver);

    // 추가적인 클레임이 없을때 오버로딩
    String generateToken(User user);

    String generateToken(
            Map<String, Object> extraClaims,
            User user
    );

    Claims extractAllClaims(String token);

    boolean isTokenValid(String token, User user);
}

일단 Jwt 토큰 관련 로직을 담당하는 인터페이스는 이렇다.

JwtServiceImpl.java

@Service
public class JwtServiceImpl implements JwtService {
    // jwt token은 최소 256비트 이상의 키를 사용해야함
    final static private String SECRET_KEY = "b9Q4SZaZmAYmL/F7p+NDbZHIrHoOp1CFR6JAJu7opyQ=";
    final static private Integer SECOND = 1000;
    final static private Integer MINUTE = SECOND * 60;
    final static private Integer HOUR = MINUTE * 60;

    public String extractToken(HttpServletRequest request) {
        String authorization = request.getHeader("Authorization");
        if (authorization == null) {
            return null;
        }
        authorization = authorization.trim();
        if (!authorization.startsWith("Bearer")) {
            return null;
        }
        // 헤더에서 Bearer 뒤에 있는 토큰을 추출
        return authorization.substring(7);
    }

    public String extractEmail(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    // 람다 함수를 사용하여 토큰에서 클레임을 추출
    // ex) 토큰에서 userId 추출
    // extractClaim(jwt, Claims::getSubject)
    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    // 추가적인 클레임이 없을때 오버로딩
    public String generateToken(User user) {
        return generateToken(new HashMap<>(), user);
    }

    public String generateToken(
            Map<String, Object> extraClaims,
            User user
    ) {
        return Jwts.builder()
                // 서명
                .signWith(getSecretKey())
                // 클레임에 추가적인 클레임 추가
                .claims(extraClaims)
                // 클레임에 userId 추가
                .subject(user.getEmail())
                // 발급시간을 현재 시간으로 설정
                .issuedAt(new Date(System.currentTimeMillis()))
                // 만료시간을 24시간으로 설정
                .expiration(new Date(System.currentTimeMillis() + HOUR * 24))
                .compact();
    }

    public Claims extractAllClaims(String token) {
        // 11 jjwt 버전
        // return Jwts.parserBuilder().setSigningKey(getSignInKey()).build().parseClaimsJws(token).getBody();
        return Jwts
                // jwt 파서 생성
                .parser()
                // 서명키 설정
                .verifyWith(getSecretKey())
                .build()
                // 파싱할 토큰 설정
                .parseSignedClaims(token)
                // 토큰의 페이로드 반환
                .getPayload();
    }

    public boolean isTokenValid(String token, User user) {
        String email = extractEmail(token);
        // 제공한 userId와 토큰의 userId가 같고 토큰이 만료되지 않았다면 true 반환
        return (email.equals(user.getEmail())) && !isTokenExpired(token);
    }

    // 토큰이 만료되었는지 확인
    private boolean isTokenExpired(String token) {
        // 지금 시간이 토큰의 만료시간보다 늦다면 true 반환
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    // 서명키
    private SecretKey getSecretKey() {
        // 서명키의 바이트 배열을 생성
        byte[] keyBytes = SECRET_KEY.getBytes(StandardCharsets.UTF_8);
        // hmacShaKeyFor는 주어진 바이트 배열을 사용하여 HMAC-SHA 키를 생성합니다.
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

길지만 어렵지 않게 읽힌다.

중요한것은 토큰에 email을 넣는다는것!

(현재 코드는 잘못된 토큰 ( 잘못된 서명키 또는 만료된 토큰 ) 이 들어오면 Exception 들이 나는 코드입니다.

적절히 try catch로 분리하던가 예외 처리를 해야하는 코드입니다.

이제 이 서비스를 필터에 등록해보자

@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
    final private JwtService jwtService;
    final private UserRepository userRepository;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain) throws ServletException, IOException {

        String token = jwtService.extractToken(request);
        System.out.println("token = " + token);

        // 토큰이 없으면 다음 필터로 넘어감
        if (token == null) {
            System.out.println("token is null");
            filterChain.doFilter(request, response);
            return;
        }

        String email = jwtService.extractEmail(token);

        // 토큰이 유효하지 않거나 SecurityContextHolder에 인증 객체가 이미 있으면 다음 필터로 넘어감

        SecurityContext securityContext = SecurityContextHolder.getContext();

        if (email == null || securityContext.getAuthentication() != null) {
            filterChain.doFilter(request, response);
            return;
        }

        User findUser = userRepository.findByEmail(email).orElse(null);

        // 유저가 존재하지 않으면 다음 필터로 넘어감
        if (findUser == null) {
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰이 유효하지 않으면 다음 필터로 넘어감
        if (!jwtService.isTokenValid(token, findUser)) {
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰에서 이메일을 추출하여 해당하는 유저가 존재하면 SecurityContextHolder에 인증 객체를 넣어줌
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                findUser,
                null,
                findUser.getAuthorities()
        );

        securityContext.setAuthentication(authToken);

        // 인증로직에 성공하면 다음 필터로 넘어가게끔 설정!
        filterChain.doFilter(request, response);
    }
}

중요한 것은 인증에 성공하면 인증에 성공한 객체 (위에서는 UsernamePasswordAuthenticationToken 객체) 를 SecurityContext 에 넣어준다는 것이다!!

위 필터를 정의하고

SecurityConfig에 필터 위치를 지정한다.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            // csrf 설정을 비활성화
            .csrf(AbstractHttpConfigurer::disable)
            // http 기본 인증 해제
            .httpBasic(AbstractHttpConfigurer::disable)
            // form 기반 인증 해제
            .formLogin(AbstractHttpConfigurer::disable)
            // 세션 생성 정책 설정 (STATELESS: 세션을 사용하지 않음)
            .sessionManagement(
                    authorize -> authorize
                            .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(
                    authorize -> authorize
                            // 모든 요청에 대해
                            .requestMatchers("/**")
                            // 인증되지 않은 사용자에게 허용
                            .permitAll()

                            // 그 외 요청에 대해 인증된 사용자에게만 허용
                            .anyRequest()
                            .authenticated()
            )
            // 변경!! 이부분!!!!
            // jwtAuthFilter 를 UsernamePasswordAuthenticationFilter 앞에 추가
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

    return http.build();
}

이로써 필터를 커스텀 했고 올바른 토큰을 넣은 사용자는 SecurityContext 에 인증객체가 담겨있다고 판단할수있다.

간단하게 테스트 해보기!

간단하게 토큰을 만들고 토큰을 넣은뒤 SecurityContext 객체를 확인해보자!

AuthController.java

// AuthController.java

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
    final private JwtService jwtService;
    final private UserRepository userRepository;
    
    
    // 테스트 토큰 발행을 위한 테스트 api ( test1 이메일의 유저가 데이터베이스에 존재하여야만 한다!)
    @GetMapping("/token")
    public ResponseEntity<String> token() {
        User findUser = userRepository.findByEmail("test1").get();
        return ResponseEntity.ok(jwtService.generateToken(findUser));
    }
}

UserController.java

// UserController.java

@RestController
@RequestMapping("/api/v1/users")
public class UserController {
    
		// 토큰을 넣어 확인해 볼 api
    @GetMapping
    public ResponseEntity<String> sayHello() {
        
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        System.out.println("auth = " + auth);
        System.out.println("auth.getPrincipal() = " + auth.getPrincipal());
        System.out.println("auth.getAuthorities() = " + auth.getAuthorities());
        System.out.println("auth.getCredentials() = " + auth.getCredentials());
        System.out.println("auth.getName() = " + auth.getName());
        System.out.println("auth.getDetails() = " + auth.getDetails());

        return ResponseEntity.ok("Hello");
    }
}

먼저 토큰없이 /api/v1/users 를 호출해보자

일단 응답은 잘된다 (왜냐하면 다 열어놨거든요… )

출력을 확인해보자

SecurityContext 인증 객체에 뭐가 담기긴 했다…

하지만 이는 디폴트 값으로 별다른 인증객체가 없으면 담기는 친구이다.

토큰을 발행하고 헤더에 토큰을 넣어 다시 호출해보자

발행후

토큰을 넣어 호출해보자

이제 출력을 확인해보면

우리가 넣은 인증객체가 이쁘게 나오는것을 확인 가능하다!!

다음 포스팅에서는 실제 API 로직을 구현해 보겠습니다.

profile
소통과 기록이 무기(Weapon)인 개발자

0개의 댓글