+6

[Spring security] - Spring Boot Security Refresh Token

Trong bài viết trước, chúng ta đã cùng tìm hiểu về cách xây dựng ứng dụng Spring security với JWT để xác thực và phân quyền. Và như mọi người đã biết thì Access Token sẽ hết hạn sau một khoảng thời gian, vậy làm sao để tạo lại token mới thì trong bài viết này chúng ta sẽ tiếp tục tìm hiểu về JWT Refresh Token.

Mọi người có thể tìm đọc các bài viết liên quan tại đây!

Spring Boot Refresh Token Flow

Dựa trên source code phát triển từ bài viết trước Spring Boot Security - JWT Authentication chúng ta thêm thông tin refreshToken trả về khi login thành công.

Thông thường chúng ta thường cấu hình thời gian hết hạn của Refresh Token sẽ dài hơn của Access Token.

Khi truy cập Server với Access Token đã hết hạn, Client sẽ được yêu cầu lấy lại Access Token mới dựa trên thông tin Refresh Token được cấp trước đó.

Refresh Token Request and Response

Requests

  • TokenRefreshRequest: { refreshToken }

Responses

  • JwtResponse: { accessToken, type, refreshToken, id, username, email, roles }
  • MessageResponse: { message }
  • TokenRefreshResponse: { accessToken, type, refreshToken }

TokenRefreshRequest

package tiendv.example.payload.request;
// imports

public class TokenRefreshRequest {
    @NotBlank
    private String refreshToken;

    public String getRefreshToken() {
        return refreshToken;
    }

    public void setRefreshToken(String refreshToken) {
        this.refreshToken = refreshToken;
    }
}

JwtResponse

// imports

import java.util.List;

public class JwtResponse {
    private String token;
    private String type = "Bearer";
    private String refreshToken;
    private Long id;
    private String username;
    private String email;
    private List<String> roles;

    public JwtResponse(String accessToken, String refreshToken, Long id, String username, String email, List<String> roles) {
        this.token = accessToken;
        this.refreshToken = refreshToken;
        this.id = id;
        this.username = username;
        this.email = email;
        this.roles = roles;
    }

    public String getToken() {
        return token;
    }
    
    // getter/setter
}    

TokenRefreshResponse

package tiendv.example.payload.response;

public class TokenRefreshResponse {
    private String accessToken;
    private String refreshToken;
    private String tokenType = "Bearer";

    public TokenRefreshResponse(String accessToken, String refreshToken) {
        this.accessToken = accessToken;
        this.refreshToken = refreshToken;
    }

    // getter/setter
}

Renew

Trong AuthController:

  • update trong api /signin thêm thông tin Refresh Token
  • thêm api refreshToken tạo mới Access Token từ Refresh Token

AuthController

package tiendv.example.controller;
// imports

@CrossOrigin(origins = "*", maxAge = 3600)
@RestController
@RequestMapping("/api/auth")
public class AuthController {
    ...

    @PostMapping("/signin")
    public ResponseEntity<?> login(@Valid @RequestBody LoginRequest loginRequest) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword()));

        SecurityContextHolder.getContext().setAuthentication(authentication);
        UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
        String jwt = jwtUtils.generateJwtToken(userDetails);

        List<String> roles = userDetails.getAuthorities().stream()
                .map(item -> item.getAuthority())
                .collect(Collectors.toList());

        RefreshToken refreshToken = refreshTokenService.createRefreshToken(userDetails.getId());

        return ResponseEntity.ok(new JwtResponse(
                jwt,
                refreshToken.getToken(),
                userDetails.getId(),
                userDetails.getUsername(),
                userDetails.getEmail(),
                roles));
    }

    @PostMapping("/refreshtoken")
    public ResponseEntity<?> refreshtoken(@Valid @RequestBody TokenRefreshRequest request) {
        String requestRefreshToken = request.getRefreshToken();

        return refreshTokenService.findByToken(requestRefreshToken)
                .map(refreshTokenService::verifyExpiration)
                .map(RefreshToken::getUser)
                .map(user -> {
                    String token = jwtUtils.generateTokenFromUsername(user.getUsername());
                    return ResponseEntity.ok(new TokenRefreshResponse(token, requestRefreshToken));
                })
                .orElseThrow(() -> new TokenRefreshException(requestRefreshToken,
                        "Refresh token is not in database!"));
    }
}

Trong phương thức refreshtoken:

  • Đầu tiên, lấy Refresh Token từ HTTP Request
  • Tiếp theo lấy RefreshToken(id, User, token, expiryDate) trong DB để thực hiện validate, verify.
  • Tiếp tục lấy thông tin User từ RefreshToken để tạo mới Access Token và trả về TokenRefreshResponse
  • Nếu có lỗi xảy ra throw TokenRefreshException

RefreshToken Service

RefreshToken

Class này quan hệ 1-1 với User.

package tiendv.example.model;
// imports

@Entity(name = "refreshtoken")
public class RefreshToken {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private long id;

    @OneToOne
    @JoinColumn(name = "user_id", referencedColumnName = "id")
    private User user;

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

    @Column(nullable = false)
    private Instant expiryDate;

    public RefreshToken() {
    }
    // getter/setter
}

RefreshTokenRepository

package tiendv.example.repository;
// imports

@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByToken(String token);

    @Modifying
    int deleteByUser(User user);
}

RefreshTokenService

package tiendv.example.security.service;
// imports

@Service
public class RefreshTokenService {
    @Value("${jwt.app.jwtRefreshExpirationMs}")
    private Long refreshTokenDurationMs;

    @Autowired
    private RefreshTokenRepository refreshTokenRepository;

    @Autowired
    private UserRepository userRepository;

    public Optional<RefreshToken> findByToken(String token) {
        return refreshTokenRepository.findByToken(token);
    }

    public RefreshToken createRefreshToken(Long userId) {
        RefreshToken refreshToken = new RefreshToken();

        refreshToken.setUser(userRepository.findById(userId).get());
        refreshToken.setExpiryDate(Instant.now().plusMillis(refreshTokenDurationMs));
        refreshToken.setToken(UUID.randomUUID().toString());

        refreshToken = refreshTokenRepository.save(refreshToken);
        return refreshToken;
    }

    public RefreshToken verifyExpiration(RefreshToken token) {
        if (token.getExpiryDate().compareTo(Instant.now()) < 0) {
            refreshTokenRepository.delete(token);
            throw new TokenRefreshException(token.getToken(), "Refresh token was expired. Please make a new signin request");
        }

        return token;
    }

    @Transactional
    public int deleteByUserId(Long userId) {
        return refreshTokenRepository.deleteByUser(userRepository.findById(userId).get());
    }
}

Handle Exception

Tạo class TokenRefreshException kế thừa class RuntimeException.

TokenRefreshException

package tiendv.example.exception;
// imports

@ResponseStatus(HttpStatus.FORBIDDEN)
public class TokenRefreshException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    public TokenRefreshException(String token, String message) {
        super(String.format("Failed for [%s]: %s", token, message));
    }
}

Cuối cùng là tạo class TokenControllerAdvice gắn annotation RestControllerAdvice để hande exception trong ứng dụng: TokenRefreshException

package tiendv.example.advice;
// imports

@RestControllerAdvice
public class TokenControllerAdvice {

    @ExceptionHandler(value = TokenRefreshException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public ErrorMessage handleTokenRefreshException(TokenRefreshException ex, WebRequest request) {
        return new ErrorMessage(
                HttpStatus.FORBIDDEN.value(),
                new Date(),
                ex.getMessage(),
                request.getDescription(false));
    }
}

Run & Test

Signin (trả về thêm thông tin Refresh Token)

Khi Access Token hết hạn

Lấy lại Access Token từ Refresh Token

Khi Refresh Token hết hạn, yêu cầu login (signin) lại.

Tổng kết

Trên đây là hướng dẫn về cấu hình Spring security (Refresh Token) để xác thực và phân quyền người dùng. Hy vọng mọi người sẽ hiểu được ý tưởng tổng thể của bài viết và áp dụng nó vào dự án của mọi các bạn một cách thoải mái.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.