[그림일기 서비스 보고 공부하기] JWT에 username 대신 Long userId를 넣기, JwtAuthenticationToken 사용하기

오젼·2024년 10월 6일
0

저번에 연관관계 매핑을 User의 String studentNumber가 아니라 Long id로 하기로 결정했다.
-> https://velog.io/@zhy2on/JPA-기본키-연관관계-매핑과-3정규형

여기에 따라서 JWT 관련 코드도 변경이 필요해보였다.
원래는 JWT를 만들 때 username을 사용해 줬었다. 여기서 username은 사용자의 학번, 즉 User 엔티티의 studentId였다.

private String generateToken(String username, int expirationInMs) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expirationInMs);

        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(key)
                .compact();
    }

근데 이젠 User 엔티티의 PK인 Long userId를 사용해주는 것으로 변경했다.

response body에 accessToken, refreshToken, 학번을 넣어서 클라이언트에게 돌려주고,
JWT에는 userId를 넣는 게 맞아 보였다.

그리고 이전에 JWT에서 getAuthentication으로 인증 객체를 가져올 때 db에서 사용자를 조회한 다음 인증 객체를 만드는 것이 아니라 토큰에 있는 정보를 파싱해서 그걸로 인증 객체를 만들기로 했었는데
-> https://velog.io/@zhy2on/그림일기-서비스-보고-공부하기-getAuthentication에서-UserDetailsService를-사용하기-vs-사용하지-않기

그때까지만 해도 UsernamePasswordAuthenticationToken를 사용했었다.

근데 그게 아니라 그림일기 서비스를 따라 JwtAuthenticationToken을 따로 만들어서 principal에 JWT의 claims를 넣는 게 더 나을 것 같아 그렇게 변경하였다.

JwtTokenProvider

Before

@Component
public class JwtTokenProvider {
    private final Key key;
    private final int accessTokenExpirationInMs;
    private final int refreshTokenExpirationInMs;
    private final CustomUserDetailsService customUserDetailsService;

    public JwtTokenProvider(
            @Value("${app.jwt.secret}") String jwtSecret,
            @Value("${app.jwt.accessTokenExpirationInMs}") int accessTokenExpirationInMs,
            @Value("${app.jwt.refreshTokenExpirationInMs}") int refreshTokenExpirationInMs,
            CustomUserDetailsService customUserDetailsService) {
        this.key = Keys.hmacShaKeyFor(jwtSecret.getBytes());
        this.accessTokenExpirationInMs = accessTokenExpirationInMs;
        this.refreshTokenExpirationInMs = refreshTokenExpirationInMs;
        this.customUserDetailsService = customUserDetailsService;
    }

    public String generateAccessToken(Authentication authentication) {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        return generateToken(userDetails.getUsername(), accessTokenExpirationInMs);
    }

    public String generateRefreshToken(Authentication authentication) {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        return generateToken(userDetails.getUsername(), refreshTokenExpirationInMs);
    }

    private String generateToken(String username, int expirationInMs) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expirationInMs);

        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(key)
                .compact();
    }

    public String generateAccessTokenFromUsername(String username) {
        return generateToken(username, accessTokenExpirationInMs);
    }

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

        return claims.getSubject();
    }

    public void validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        } catch (ExpiredJwtException ex) {
            throw new JwtTokenExpiredException();
        } catch (MalformedJwtException | UnsupportedJwtException | IllegalArgumentException ex) {
            throw new JwtTokenInvalidException();
        }
    }

    public Authentication getAuthentication(String token) {
        String username = getUsernameFromJWT(token);
        UserDetails userDetails = customUserDetailsService.loadUserByUsername(username);
        return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
    }
}

After

@Component
public class JwtTokenProvider {

    private final String CLAIM_USER_ID = "userId";

    private final Key key;
    private final int accessTokenExpirationInMs;
    private final int refreshTokenExpirationInMs;

    public JwtTokenProvider(
            @Value("${app.jwt.secret}") String jwtSecret,
            @Value("${app.jwt.accessTokenExpirationInMs}") int accessTokenExpirationInMs,
            @Value("${app.jwt.refreshTokenExpirationInMs}") int refreshTokenExpirationInMs) {
        this.key = Keys.hmacShaKeyFor(jwtSecret.getBytes());
        this.accessTokenExpirationInMs = accessTokenExpirationInMs;
        this.refreshTokenExpirationInMs = refreshTokenExpirationInMs;
    }

    public String generateAccessToken(Long userId) {
        return generateToken(userId, accessTokenExpirationInMs);
    }

    public String generateRefreshToken(Long userId) {
        return generateToken(userId, refreshTokenExpirationInMs);
    }

	// claim에 User 엔티티의 PK인 Long userId를 포함하도록 변경
    // 그리고 userRole도 포함하도록 변경
    private String generateToken(User user, int expirationInMs) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expirationInMs);

        return Jwts.builder()
                .claim(CLAIM_USER_ID, user.getId())
                .claim(CLAIM_USER_ROLE, user.getUserRole().getKey())
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(key)
                .compact();
    }

    public Long getUserIdFromJWT(String token) {
        validateToken(token);
        Claims claims = parseClaims(token);
        return claims.get(CLAIM_USER_ID, Long.class);
    }

    public void validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
        } catch (ExpiredJwtException ex) {
            throw new JwtTokenExpiredException();
        } catch (MalformedJwtException | UnsupportedJwtException | IllegalArgumentException ex) {
            throw new JwtTokenInvalidException();
        }
    }

	// CustomUserDetailsService를 거치는 게 아니라(db 조회를 거치는 게 아니라)
    // JWT의 claim 정보를 가지고 AuthenticationToken을 만들도록 변경
    public Authentication getAuthentication(String token) {
        Claims claims = parseClaims(token);
        String userRoleKey = claims.get(CLAIM_USER_ROLE, String.class);
        List<GrantedAuthority> authorities = Collections.singletonList(new SimpleGrantedAuthority(userRoleKey));
        return new JwtAuthenticationToken(claims, null, authorities);
    }

    public Claims parseClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

자.. claim에 userId를 넣어줬다.

또 CustomUserDetailsService를 사용해 db 조회를 거치는 것이 아니라 JWT의 claim을 가지고 JwtAuthenticationToken을 바로 생성해주도록 했다.

어차피 실제로 수강신청, 관심과목 담기, 회원 탈퇴등을 수행할 때 db에서 User를 조회한 다음에 로직을 실행하게 된다. 여기서는 userId 정도만 가지고 있으면 됐다. 기존에 db 조회를 거쳐서 인증 객체를 만드는 건 불필요 했음.

JwtAuthenticationToken

New

public class JwtAuthenticationToken extends AbstractAuthenticationToken {

    private final Object principal;
    private final String credentials;

    public JwtAuthenticationToken(Object principal, String credentials) {
        super(null);
        super.setAuthenticated(false);
        this.principal = principal;
        this.credentials = credentials;
    }

    public JwtAuthenticationToken(Object principal, String credentials,
                                  Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        super.setAuthenticated(true);
        this.principal = principal;
        this.credentials = credentials;
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }

    @Override
    public String getCredentials() {
        return credentials;
    }

    @Override
    public void setAuthenticated(boolean authenticated) {
        if (authenticated) {
            throw new IllegalArgumentException("authenticated는 true로 설정할 수 없습니다.");
        }
        super.setAuthenticated(false);
    }
}

이건 그림일기 서비스에 구현된 JwtAuthenticationToken을 그대로 따라 만들었다.

AuthService

Before

	...

	public AuthenticationResult loginOrSignup(LoginRequest loginRequest) {
        User user = findOrCreateUser(loginRequest);
        Authentication authentication = authenticate(loginRequest);
        JwtTokens jwtTokens = generateTokens(authentication, user);
        return new AuthenticationResult(jwtTokens.accessToken(), jwtTokens.refreshToken(), user.getStudentId());
    }
    
    ...
    
    private Authentication authenticate(LoginRequest loginRequest) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.studentId(),
                        loginRequest.password()
                )
        );
        SecurityContextHolder.getContext().setAuthentication(authentication);
        return authentication;
    }

After

	...

	@Transactional
    public AuthenticationResult loginOrSignup(LoginRequest loginRequest) {
        try {
            User user = findOrCreateUser(loginRequest);
            authenticateUser(user, loginRequest.password());
            JwtTokens jwtTokens = generateTokens(user);
            setSecurityContextWithJwt(jwtTokens.accessToken());
            return new AuthenticationResult(jwtTokens.accessToken(), jwtTokens.refreshToken(), user.getStudentId());
        } catch (BadCredentialsException e) {
            throw new InvalidLoginException();
        }
    }
    
	...

	private void authenticateUser(User user, String rawPassword) {
        if (!passwordEncoder.matches(rawPassword, user.getPassword())) {
            throw new BadCredentialsException("Invalid password");
        }
    }

    private void setSecurityContextWithJwt(String jwt) {
        Authentication authentication = tokenProvider.getAuthentication(jwt);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
    

그리고 인증을 할 때 AuthenticationManager를 사용하는 게 아니라 직접 user를 조회해서 비밀번호를 비교하도록 구현했다. AuthenticationManager를 사용하지 않으니 CustomUserDetailsService도 필요 없어짐.

CustomUserDetailsService

Before

@Service
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

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

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByStudentId(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username));

        return org.springframework.security.core.userdetails.User.builder()
                .username(user.getStudentId())
                .password(user.getPassword())
                .roles("USER")
                .build();
    }
}

After

그래서 삭제했다.

휴....

드디어 첫 단추 리팩토링 끝
인증 과정에서 userId를 사용하도록 변경해놨더니 지금 인증 로직은 잘 되는데
나머지 수강신청, 관심과목 담기 로직들이 오류가 난다🥲

이제 1. JWT에서 username 말고 userId 사용하기가 끝났으니까
2. 연관관계 매핑에서 학번(String studentId) 말고 Long userId 사용하도록 변경하고
3. studentId 필드명 studentNumber로 변경하고
4. 양방향 매핑 설정하고
5. Cascade 옵션 설정하기....

그리고 유닛 테스트 작성까지..
유닛 테스트 만들다가 여기까지 왔다🥲
Spring Security가 얼레벌레 짜여 있으니까 유닛 테스트가 진전이 안 됐음
덕분에 Entity 리팩토링까지 먼저 해보네....

이제 다시 정신차리고 해보자!!

0개의 댓글