spring

[Spring] JWT를 활용한 로그인 Rest API

허몽구 2023. 5. 8. 00:21

정말 보기 불편했던 로그인 Rest Api를 수정해봤다.

 

- 기존 코드 - 

1. Entity

@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name="User")
@AllArgsConstructor
@Builder
public class User{

    @Id // pk
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String email; // 이메일. 얘가 아이디가 될 거임

    @Column(unique = true)
    private String password; // 비밀번호

    @Column(length = 10, unique = true)
    private String nickname; // 닉네임

    private String semester; // 학기

    private boolean graduate; // 졸업 여부

    @Column(length = 15)
    private String major1; // 전공 1

    @Column(length = 15)
    private String major2; // 전공 2

    private boolean department; // 학부

    private boolean major_minor; // 주 부 전공

    private boolean double_major; // 복수전공

    @Column(name = "is_login")
    private boolean isLogin; // 로그인 여부

    @Column(name = "route_info")
    public String routeInfo; // 루트추천 저장

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
    private List<Evaluation> evaluations = new ArrayList<>();

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
    @Fetch(FetchMode.SUBSELECT)
    private List<Route> routes = new ArrayList<>();

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

    public void addRoute(Route route) {
        routes.add(route);
        route.setUser(this);
    }
}

 

2. Repository

@Repository
public interface UserRepository extends JpaRepository<User, Integer>{
    @EntityGraph(attributePaths = {"routes"})
    Optional<User> findByEmail(String email);

    Optional<User> findByNickname(String nickname);
}

 

3. Service

@Service
@Transactional
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public User findByEmail(String email){
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("유저를 찾을 수 없습니다." + email));
    }

    public void save(User user) {
        userRepository.save(user);
    }
}

 

4. Controller

 @PostMapping("/users/login") // 로그인
    public Map<String, Object> login(@RequestBody loginRequest request, HttpSession httpSession, HttpServletResponse response) {
        User user = userService.findByEmail(request.getEmail());
        Map<String, Object> responseBody = new HashMap<>();

        if (user == null) {
            responseBody.put("status", "error");
            responseBody.put("message", "User not found");
            return responseBody;
        }

        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
            responseBody.put("status", "error");
            responseBody.put("message", "Invalid password");
            return responseBody;
        }

        user.setLogin(true); // 로그인 상태 업데이트
        userService.save(user); // 변경된 로그인 상태 저장

        httpSession.setAttribute("user", user); // 세션에 로그인 정보 유지

        // 세션 아이디를 쿠키에 추가
        Cookie sessionCookie = new Cookie("sessionId", httpSession.getId());
        sessionCookie.setHttpOnly(true);
        sessionCookie.setMaxAge(-1); // 브라우저를 닫으면 쿠키 삭제
        response.addCookie(sessionCookie);

        responseBody.put("sessionId", httpSession.getId());
        responseBody.put("nickname", user.getNickname());
        responseBody.put("major1", user.getMajor1());
        responseBody.put("major2", user.getMajor2());
        responseBody.put("semester", user.getSemester());
        responseBody.put("department", user.isDepartment());
        responseBody.put("email", user.getEmail());
        responseBody.put("password", user.getPassword());
        responseBody.put("graduate", user.isGraduate());
        responseBody.put("major_minor", user.isMajor_minor());
        responseBody.put("double_major", user.isDouble_major());
        responseBody.put("login", user.isLogin());
        responseBody.put("message", "Login Success");

        return responseBody;
    }

 

코드가 너무 너무 너무.. 더럽다. 이때 코드를 적을 때에도 느꼈지만 다시 보니까 너무 별로다...ㅠㅠ

세션과 쿠키를 사용했었는데 이번엔 JWT를 사용하기로 했다.

JWT를 쓰려면 build.gradle에 다음 dependencies를 추가해줘야 한다.

    implementation "io.jsonwebtoken:jjwt:0.9.1"
    implementation 'org.springframework.boot:spring-boot-starter-security'

 

이후 Security Config 파일을 생성해준다. Security Config 파일은 Spring Security 설정을 구성하는 데 사용된다.

애플리케이션의 보안 관련 구성 요소를 정의하고, 인증 및 권한 부여 메커니즘을 구성하는데 사용된다.

 

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig{
    private final TokenProvider tokenProvider;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/login").permitAll() // 로그인은 인증하지 않은 모든 사용자 허용
                .antMatchers("/swagger-ui.html/**").permitAll() // 스웨거
                .and()
                .authorizeRequests()
                .anyRequest().authenticated() // 그 외의 요청은 인증된 사용자만 접근 가능
                .and()
                .addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

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

TokenProvider라는 생성자를 주입해주고 있고, JwtFilter를 사용하고 있다. 

내가 작성한 TokenProvider은 다음과 같다.

 

@Slf4j
@Component
public class TokenProvider {

    private final Key key;
    private final long validityTime;

    public TokenProvider(
            @Value("${JWT_SECRET_KEY}") String secretKey,
            @Value("${jwt.token-validity-in-milliseconds}") long validityTime) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.validityTime = validityTime;
    }

    public TokenDto generateToken(Authentication authentication){
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .setExpiration(new Date(System.currentTimeMillis() + validityTime))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        String refreshToken = Jwts.builder()
                .setExpiration(new Date(System.currentTimeMillis() + (validityTime * 6)))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return TokenDto.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }

    public Authentication getAuthentication(String accessToken){
        Claims claims = parseClaims(accessToken);

        if(claims.get("auth") == null){
            throw new RuntimeException("권한 정보가 없습니다");
        }

        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.error("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.error("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.error("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.error("JWT claims string is empty.", e);
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

TokenProvider는 JWT를 사용하여 보안 토큰을 생성하고 검증하는 역할을 수행한다.

access Token, refresh Token를 생성한다.

토큰은 사용자 인증에 사용되며, 인증된 사용자에 대한 정보를 포함하고 있다.

@Value에서 SecretKey를 설정하는 방법은 application.yml파일을 수정하면 되는데,

 

jwt:
  secret: ${ JWT_SECRET_KEY }
  token-validity-in-milliseconds: 86400000

이런 식으로 설정해뒀다. ${}는 environment variables에서 설정할 수 있다.

 

jwt:
  secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWppd29vbi1zcHJpbmctYm9vdC1zZWN1cml0eS1qd3QtdHV0b3JpYWwK
  token-validity-in-milliseconds: 86400000

JWT의 Secret Key는 JWT 토큰을 생성하고 검증할 때 사용되는 비밀키이다.

이 Secret Key는 서버 측에서만 알고 있어야 하며, 토큰의 무결성을 보장하고 토큰을 조작하지 않도록 하는 역할을 한다.

Secret Key의 중요한 점은 비밀성과 안정성이기 때문에 랜덤하고 예측 불가능한 값이어야 하며, 외출에 노출되어서는 안 된다.

그래서 environment variables에서 설정하는 걸 추천한다!

 

다음 코드는 JwtFilter이다.

@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {
    private final TokenProvider tokenProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = resolveToken((HttpServletRequest) request);
        // 토큰 유효성 검사
        if(token != null && tokenProvider.validateToken(token)){
            Authentication authentication = tokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    // 헤더에서 토큰 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

토큰의 유효성을 검사하고 헤더에서 토큰을 추출하는 메소드가 있다.

 

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

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        return userRepository.findByEmail(email)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다."));
    }
}

loadUserByUsername은 Spring Security에서 제공하는 인터페이스인 UserDetailsService의 메서드이다.

주어진 사용자명을 기반으로 사용자의 상세 정보를 검색하고 반환하는 역할을 한다.

검색된 사용자 정보는 인증 과정에서 비밀번호 비교 등을 통해 사용자를 인증하는데 사용된다.

 

설정이 모두 끝났다! 이제 코드를 수정해보자.

 

1. Service

public TokenDto login(String email, String password) {
        // ID/PW 를 기반으로 Authentication 객체 생성
        // authentication 객체는 인증 여부를 확인하는 authenticated 값이 false
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password);

        // 인증된 정보를 기반으로 JWT 토큰 생성
        TokenDto tokenDTO = tokenProvider.generateToken(authenticationToken);

        // 로그인 성공하면 토큰DTO에 제대로 들어감
        return tokenDTO;
    }

TokenDto를 이용한 로그인 서비스를 만들어준다. 

이메일과 비밀번호를 매개변수로 받고 토큰DTO를 리턴한다.

 

2. TokenDto

@Builder
@Data
@AllArgsConstructor
public class TokenDto {
    private String grantType;
    private String accessToken;
    private String refreshToken;
}

토큰을 반환하는 TokenDto이다.

 

3. Controller

    @PostMapping("/login") // 로그인
    @ResponseStatus(HttpStatus.OK)
    public ApiResponseDto<TokenDto> login(@RequestBody @Valid final loginRequest request) {
        TokenDto tokenDto = userService.login(request.getEmail(), request.getPassword());
        User user = userService.findByEmail(request.getEmail());
        user.updateLoginStatus(true);
        userService.save(user);
        return ApiResponseDto.success(SuccessStatus.LOGIN_SUCCESS, tokenDto);
    }

서비스단의 로그인을 진행해주고, 사용자의 로그인 상태를 true로 업데이트 해준다.

 

4. SuccessStatus

@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public enum SuccessStatus {
    /*
    user
     */
    SIGNUP_SUCCESS(HttpStatus.CREATED, "회원가입이 완료되었습니다."),

    LOGIN_SUCCESS(HttpStatus.OK, "로그인 성공!"),

    CERTIFICATION_SUCCESS(HttpStatus.OK, "유저 인증 성공")
    ;

    private final HttpStatus httpStatus;
    private final String message;
}