스프링 시큐리티 없이 JWT 회원가입 및 로그인 구현

2024. 3. 8. 23:28Spring/Java

 

 

스프링 시큐리티 없이 JWT를 이용한 회원가입 및 로그인 구현을 정리하고자 한다. 시큐리티를 쓰지 않는 이유는 시큐리티 자체가 너무 많은 역할을 해주기 때문에 직접 구현하며 어떤 식으로 진행되는지 확인하고자 함이다. 물론 내가 진행한 프로젝트 내에서 스프링 시큐리티를 아예 쓰지 않는 것은 아니지만 JWT 인증에 한해서는 사용하지 않는다.

 

0. JWT 에 관해

이 글을 읽기 전 JWT에 관한 이해가 필요하다. 앞선 글을 읽고 오길 바란다.

2024.02.23 - [Web] - JWT(Json Web Token) 이란 무엇인가? - 쿠키와 세션 비교를 통해

 

1. dependency 설치

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

JWT를 이용하기 위한 의존성들이다. 구글링 했을 때 0.11 버전도 많은데 11과 12 버전은 함수 자체가 다른 것도 많기 때문에 버전에 맞는 것으로 찾는 것이 좋다.

 

2. User

유저 정보를 담을 User Entity를 만들어 준다.

@Entity
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "id", callSuper = false)
@Table(name = "`user`")
public class User extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    @Column(unique = true, nullable = false)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(nullable = false)
    private String nickname;

    @Column
    private Integer age;
}

 

BaseEntity는 createAt, updatedAt의 컬럼이 들어있다. User 안의 컬럼으로는 서비스에서 필요한 내용에 따라 바뀔 것이다.

 

또한 username을 가지고 회원을 조회할 수 있는 repository 를 만들어준다.

public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByUsername(String username);
}

3. Controller 구현

클라이언트에서 요청을 보냈을 때 받을 수 있도록 controller를 먼저 구현한다. 회원가입, 로그인을 구현하기 위해 두 가지 메소드를 구현해 주었다.

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

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/signUp")
    public ResponseEntity<TokenResponse> signUp(@RequestBody SignUpRequest request) {
        if (authService.findByUsername(request.username()).isPresent()) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        }
        return ResponseEntity.status(HttpStatus.CREATED).body(authService.signUp(request));
    }

    @PostMapping("/signIn")
    public ResponseEntity<TokenResponse> signIn(@RequestBody SignInRequest request) {
        return ResponseEntity.status(HttpStatus.OK).body(authService.signIn(request));
    }
}

 

PostMapping을 이용할 때 받는 RequestBody는 모두 미리 만들었다.

JWT를 이용하기 때문에 TokenResponse라는 타입의 ResponseEntity를 반환할 것이다.

@Builder
public record TokenResponse(
        String accessToken,
        String refreshToken
) {

}

TokenResponse에는 accessToken과 refreshToken을 담았다. refreshToken을 매번 새로 발급하여 보내주면 보안이 더 좋아질 것이다. 하지만 요청마다 refreshToken도 발급을 새로 해야 하기 때문에 서버에 조금 더 부하가 생길 수는 있을 것이다. 이 부분은 추후 /refresh의 경로를 만들어서 accessToken이 만료된 것을 확인한 이후 refreshToken을 요청할 수 있도록 분리하면 좋을 것 같다.

public record SignUpRequest(
        String username,
        String password,
        String email,
        String nickname,
        Integer age
) {

}
public record SignInRequest(
        String username,
        String password
) {

}

signUp 메소드에서 RequestDTO를 받는다. 이 DTO 유저가 존재하는지 확인하고 존재한다면 Bad Request를 아니라면 서비스 단에서 회원가입을 시키는 로직으로 연결시킬 것이다.

여기서도 그냥 Bad Reqeust 말고 상황에 따른 예외 메시지를 전달하는 것으로 추후 리팩토링이 필요할 것이다.

 

SignIn 메소드는 받은 request를 서비스로 넘길 것이다.

 

자, 이제 컨트롤러는 구현이 끝이 났다. 서비스 구현 하기 전 JWT를 관리하는 클래스를 하나 만들 것이다.

4. JwtUtil 클래스

많은 사람들이 JwtUtil로 이름을 짓길래 나도 이렇게 지었다.

@Component
public class JwtUtil {

    private final SecretKey secretKey;
    
    @Value("${jwt.access-token-expiration}")
    private Long accessExpiration;

    @Value("${jwt.refresh-token-expiration}")
    private Long refreshExpiration;

    public JwtUtil(@Value("${jwt.secret}") String secret) {
        this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8),
                SIG.HS256.key().build().getAlgorithm());
    }

    public String createAccessToken(User user) {
        return Jwts.builder()
                .claim("username", user.getUsername())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + accessExpiration))
                .signWith(secretKey)
                .compact();
    }

    public String createRefreshToken(User user) {
        return Jwts.builder()
                .claim("username", user.getUsername())
                .expiration(new Date(System.currentTimeMillis() + refreshExpiration))
                .signWith(secretKey)
                .compact();
    }

    public boolean isValidRefreshToken(String refreshToken) {
        try {
            getClaimsToken(refreshToken);
            return true;
        } catch (NullPointerException | JwtException e) {
            return false;
        }
    }

    private Claims getClaimsToken(String token) {
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

}

 

우선 @Component 어노테이션을 통해 스프링에서 관리할 Bean으로 등록시켜 준다.

createAccessToken, createRefreshToken 메소드를 보면 맨 처음에 받은 의존성에서 Jwts 클래스를 이용한다. claim메소드를 통해 user를 식별할 수 있는 username 정보를 넣어줬고 만료시간을 담고 서버에서 가진 secretKey를 이용해 서명을 추가한 후 compact 메소드를 통해 토큰을 문자열로 반환해 준다.

 

5. RefreshToken

리프레쉬 토큰을 entity로 만들어 사용자와 맵핑해준다.

@Entity
@RequiredArgsConstructor
@Getter
public class RefreshToken {

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

    @Column
    private String token;

    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;

    @Builder
    public RefreshToken(String token, User user) {
        this.token = token;
        this.user = user;
    }

    public void updateToken(String token) {
        this.token = token;
    }
}

 

마찬가지로 토큰을 통해 유저를 조회하기 위해 repository도 만들었다.

import com.chatty.chatty.auth.entity.RefreshToken;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {

    Optional<RefreshToken> findByUserId(Long userId);
}

 

자 이제 준비가 끝났다. 서비스만 구현하면 끝이 난다.

6. Service

@Service
@RequiredArgsConstructor
public class AuthService {

    private final JwtUtil jwtUtil;
    private final UserRepository userRepository;
    private final RefreshTokenRepository refreshTokenRepository;

    @Transactional
    public TokenResponse signUp(SignUpRequest request) {
        User newUser = User.builder()
                .username(request.username())
                .password(new BCryptPasswordEncoder().encode(request.password()))
                .email(request.email())
                .nickname(request.nickname())
                .age(request.age())
                .build();
        String accessToken = jwtUtil.createAccessToken(newUser);
        String refreshToken = jwtUtil.createRefreshToken(newUser);

        userRepository.save(newUser);
        refreshTokenRepository.save(
                RefreshToken.builder()
                        .user(newUser)
                        .token(refreshToken)
                        .build()
        );
        return TokenResponse.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    //TODO: 예외처리 및 예외 메시지 분리
    @Transactional
    public TokenResponse signIn(SignInRequest request) {
        User user = userRepository.findByUsername(request.username())
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
        if (!new BCryptPasswordEncoder().matches(request.password(), user.getPassword())) {
            throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
        }
        RefreshToken refreshTokenEntity = refreshTokenRepository.findByUserId(user.getId())
                .orElseThrow(() -> new IllegalArgumentException("토큰이 존재하지 않습니다."));
        String accessToken = "";
        String refreshToken = refreshTokenEntity.getToken();
        if (jwtUtil.isValidRefreshToken(refreshToken)) {
            accessToken = jwtUtil.createAccessToken(user);
            return TokenResponse.builder()
                    .accessToken(accessToken)
                    .refreshToken(refreshToken)
                    .build();
        } else {
            refreshToken = jwtUtil.createRefreshToken(user);
            refreshTokenEntity.updateToken(refreshToken);
        }
        return TokenResponse.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    public Optional<User> findByUsername(String username) {
        return userRepository.findByUsername(username);
    }
}

우선 signUp, signIn 메소드 모두 트랜잭션 처리를 해주었다.

signUp 메소드에서는 패스워드를 암호화하여 저장하기 위해 스프링 시큐리티에서 제공하는 BCryptPasswordEncoder를 사용하였다. (꼭 해당 encoder를 사용하지 않아도 괜찮다)

 

request를 통해 얻은 정보로 user를 생성하여 저장하였고 access token과 refresh token도 각각 발급하였다. 해당 토큰들을 이용하여 TokenRepsonse를 통해 반환해 주었다.

 

signIn 메소드는 보이는 바와 같이 예외 처리, 예외 메시지 분리가 필요하다.

 

우선 request를 통해 받은 정보로 user를 가져온다. 리프레시 토큰을 확인해서 유효하다면 access token만 업데이트해줘서 response를 날린다. 만약 valid 하지 않다면, 즉 만료되었다면 리프레시 토큰도 새로 업데이트해준다. 이후 Tokenresponse를 통해 반환해 준다.

 

7. 결과

 

이렇게 해서 스프링을 이용해 JWT를 이용한 인증을 구현해 보았다. 스프링 시큐리티 없이 구현하여 회원가입, 로그인이 어떤 식으로 구현되었는지 되돌아볼 수 있었다.