Skip to content

Commit

Permalink
feat: implement auto-login and token management
Browse files Browse the repository at this point in the history
- Enabled automatic login for users.
- Added functionality to issue refresh tokens.
- Implemented access token reissuance using refresh tokens.
  • Loading branch information
inpink committed Aug 2, 2024
1 parent dc20698 commit 51688df
Show file tree
Hide file tree
Showing 13 changed files with 116 additions and 62 deletions.
6 changes: 3 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ grant_type=authorization_code&code=AUTH_CODE&redirect_uri=YOUR_REDIRECT_URI&clie
- [X] API는 Swagger에서 확인 가능하다.
- [X] http://localhost:8080/swagger-ui.html

- 유저는 자동 로그인을 할 수 있다.
- refresh token을 발급받아 저장한다.
- refresh token을 통해 access token을 재발급 받는다.
- [X] 유저는 자동 로그인을 할 수 있다.
- [X] refresh token을 발급할 수 있다.
- [X] refresh token을 통해 access token을 재발급 받는다.
- [X] 프론트에서 access token 보낼 때 401 => refresh token 안보냄(사용자가 직접 로그아웃/블랙리스트 등)
- [X] 프론트에서 access token 보낼 때 403 => refresh token이 있으면 보내주세욥(모종의 이유로 토큰이 올바르지 않음. 재인증 필요)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@
import app.cardcapture.auth.google.dto.GoogleLoginRequestDto;
import app.cardcapture.auth.google.dto.GoogleTokenResponseDto;
import app.cardcapture.auth.google.service.GoogleAuthService;
import app.cardcapture.auth.jwt.dto.JwtDto;
import app.cardcapture.auth.jwt.domain.Claims;
import app.cardcapture.auth.jwt.dto.JwtResponseDto;
import app.cardcapture.auth.jwt.dto.RefreshTokenRequestDto;
import app.cardcapture.auth.jwt.service.JwtComponent;
import app.cardcapture.common.dto.SuccessResponseDto;
import app.cardcapture.common.utils.TimeUtils;
import app.cardcapture.user.domain.entity.User;
import app.cardcapture.user.dto.UserDto;
import app.cardcapture.user.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.transaction.Transactional;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
Expand Down Expand Up @@ -48,30 +54,51 @@ public ResponseEntity<SuccessResponseDto<GoogleLoginRequestDto>> getGoogleLoginD
SuccessResponseDto responseDto = SuccessResponseDto.create(googleLoginRequestDto);

return ResponseEntity.ok(responseDto);
} // TODO: 쿠키에 넣어주면 프론트가 편할 수 있다 고려해볼 것
}

@GetMapping("/redirect") // TODO: 만약 access token이 만료됐으면, 프론트에서 refreshtoken이 있으면 보내달라는 의미로 에러코드를 다른 걸로 보내줘야함(상황에 따라 프론트에서 기대되는 동작이 다르기 때문에 이를 사전에 정해놓는 것 필요)
// 지금은 다 403보내고있는데, 이를 구분해주기위한 용도로 에러코드를 이용할 수 있다.
@GetMapping("/redirect")
@Operation(summary = "구글 리다이렉트 엔드포인트", description = "구글 리다이렉트를 통해 받은 auth code를 받습니다. auth code를 이용하여 유저 정보를 가져올 것입니다.")
@Transactional //TODO: 추후 serivce로 분리하면 service계층에만 transactional달기
public ResponseEntity<SuccessResponseDto<JwtDto>> getGoogleRedirect(@RequestParam(name = "code") String authCode)
{
// TODO: Service로 좀 감추기 (테스트 관점에서도 복잡도 감소 가능)
GoogleTokenResponseDto googleTokenResponseDto = googleAuthService.getGoogleToken(authCode);
UserDto userDto = googleAuthService.getUserInfo(googleTokenResponseDto.getAccessToken());
@Transactional
public ResponseEntity<SuccessResponseDto<JwtResponseDto>> getGoogleRedirect(
@RequestParam(name = "code") String authCode
) {
GoogleTokenResponseDto googleTokenResponseDto = googleAuthService.getGoogleToken(authCode);
UserDto userDto = googleAuthService.getUserInfo(googleTokenResponseDto.getAccessToken());

Optional<User> existingUserOpt = userService.findByGoogleId(userDto.getGoogleId());

User user = existingUserOpt.orElseGet(() -> {
return userService.save(userDto);
});

String jwt = jwtComponent.createAccessToken(user.getId(), "ROLE_USER", Date.from(user.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant()));
String refreshToken = jwtComponent.createRefreshToken(user.getId());
JwtResponseDto jwtResponseDto = new JwtResponseDto(jwt, refreshToken);

SuccessResponseDto responseDto = SuccessResponseDto.create(jwtResponseDto);

Optional<User> existingUserOpt = userService.findByGoogleId(userDto.getGoogleId());
return ResponseEntity.ok(responseDto);
}

@PostMapping("/refresh")
@Operation(summary = "JWT 갱신", description = "리프레시 토큰을 이용하여 새로운 JWT를 반환합니다.")
public ResponseEntity<SuccessResponseDto<JwtResponseDto>> refreshJwt(
@RequestBody @Valid RefreshTokenRequestDto refreshTokenRequest
) {
String refreshToken = refreshTokenRequest.refreshToken();

User user = existingUserOpt.orElseGet(() -> {
// 신규 유저라면 저장
return userService.save(userDto);
});
Claims claims = jwtComponent.verifyRefreshToken(refreshToken);

String jwt = jwtComponent.create(user.getId(), "ROLE_USER", Date.from(user.getCreatedAt().atZone(ZoneId.systemDefault()).toInstant()));
JwtDto jwtDto = new JwtDto(jwt);
Long userId = claims.getId();
UserDto userDto = userService.findUserById(userId);

SuccessResponseDto responseDto = SuccessResponseDto.create(jwtDto);
Date userCreatedAt = TimeUtils.toDate(userDto.getCreatedAt());
String newJwt = jwtComponent.createAccessToken(userId, "ROLE_USER", userCreatedAt);
String newRefreshToken = jwtComponent.createRefreshToken(userId);

return ResponseEntity.ok(responseDto);
JwtResponseDto jwtResponseDto = new JwtResponseDto(newJwt, newRefreshToken);
SuccessResponseDto responseDto = SuccessResponseDto.create(jwtResponseDto);

return ResponseEntity.ok(responseDto);
}
}
}
19 changes: 12 additions & 7 deletions src/main/java/app/cardcapture/auth/jwt/config/JwtConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,22 @@
public class JwtConfig {
private String issuer;
private String secret;
private Long expirationInSeconds;
private Long accessExpirationInSeconds;
private Long refreshExpirationInSeconds;

public long getExpirationMillis(long current) {
return current + expirationInSeconds * 1000L;
public long getAccessExpirationMillis(long current) {
return current + accessExpirationInSeconds * 1000L;
}

public Date getExpirationDate(Date current) {
return new Date(getExpirationMillis(current.getTime()));
public Date getAccessExpirationDate(Date current) {
return new Date(getAccessExpirationMillis(current.getTime()));
}

public LocalDateTime getExpirationDate(LocalDateTime current) {
return current.plusSeconds(expirationInSeconds.intValue());
public LocalDateTime getAccessExpirationDate(LocalDateTime current) {
return current.plusSeconds(accessExpirationInSeconds.intValue());
}

public Date getRefreshExpirationDate(Date current) {
return new Date(current.getTime() + refreshExpirationInSeconds * 1000L);
}
}
2 changes: 1 addition & 1 deletion src/main/java/app/cardcapture/auth/jwt/domain/Claims.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import java.util.Date;

@Getter
public class Claims {
public class Claims { //TODO: Access Token, Refresh Token 용 Claims 구분하기(NullpointerException 방지)
private Long id;
private String[] roles;
private Date issuedAt;
Expand Down
12 changes: 0 additions & 12 deletions src/main/java/app/cardcapture/auth/jwt/dto/JwtDto.java

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package app.cardcapture.auth.jwt.dto;

import jakarta.validation.constraints.NotBlank;

public record JwtResponseDto(
@NotBlank String accessToken,
@NotBlank String refreshToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package app.cardcapture.auth.jwt.dto;

import jakarta.validation.constraints.NotBlank;

public record RefreshTokenRequestDto(
@NotBlank String refreshToken
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
authHeader = authHeader.substring(7);
}
String authToken = authHeader;
Claims claims = jwtComponent.verify(authToken);
Claims claims = jwtComponent.verifyAccessToken(authToken);
if (claims != null) {
Long userId = claims.getId();
if (userId != null) {
Expand Down
29 changes: 24 additions & 5 deletions src/main/java/app/cardcapture/auth/jwt/service/JwtComponent.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,25 +39,37 @@ public JwtComponent(JwtConfig jwtConfig, TokenBlacklistService tokenBlacklistSer
this.userService = userService;
}

public String create(Long userId, String role, Date createdAt) {
return this.create(Claims.of(userId, role, jwtConfig.getIssuer(), createdAt));
public String createAccessToken(Long userId, String role, Date createdAt) {
return this.createAccessToken(Claims.of(userId, role, jwtConfig.getIssuer(), createdAt));
}

public String create(Claims claims) {
public String createAccessToken(Claims claims) {
Date now = new Date();

JWTCreator.Builder builder = JWT.create();
builder.withIssuer(claims.getIssuer());
builder.withIssuedAt(now);
builder.withExpiresAt(jwtConfig.getExpirationDate(now));
builder.withExpiresAt(jwtConfig.getAccessExpirationDate(now));
builder.withClaim("id", claims.getId());
builder.withArrayClaim("roles", claims.getRoles());
builder.withClaim("created_at", claims.getCreatedAt());

return builder.sign(jwtHashAlgorithm);
}

public Claims verify(String token) {
public String createRefreshToken(Long userId) {
Date now = new Date();
Date expirationDate = jwtConfig.getRefreshExpirationDate(now);

return JWT.create()
.withIssuer(jwtConfig.getIssuer())
.withIssuedAt(now)
.withExpiresAt(expirationDate)
.withClaim("id", userId)
.sign(jwtHashAlgorithm);
}

public Claims verifyAccessToken(String token) {
verifyBlacklisted(token);

DecodedJWT decodedJWT = verifyJWT(token);
Expand All @@ -68,6 +80,13 @@ public Claims verify(String token) {
return claims;
}

public Claims verifyRefreshToken(String token) {
DecodedJWT decodedJWT = verifyJWT(token);
Claims claims = new Claims(decodedJWT);

return claims;
}

private DecodedJWT verifyJWT(String token) {
DecodedJWT decodedJWT;
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public void addToBlacklist(String token) {

TokenBlacklist tokenBlacklist = new TokenBlacklist();
tokenBlacklist.setToken(refinedToken);
tokenBlacklist.setExpiryDate(jwtConfig.getExpirationDate(LocalDateTime.now()));
tokenBlacklist.setExpiryDate(jwtConfig.getAccessExpirationDate(LocalDateTime.now()));
tokenBlacklistRepository.save(tokenBlacklist);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,7 @@ public class UserController {

@GetMapping("/me")
@Operation(summary = "사용자 정보 조회",
description = "현재 로그인한 사용자의 정보를 조회합니다. " +
"JWT를 통해 사용자를 식별합니다. " +
"JWT가 유효하지 않으면 403을 반환합니다.")
description = "현재 로그인한 사용자의 정보를 조회합니다. JWT를 통해 사용자를 식별합니다. ")
public ResponseEntity<SuccessResponseDto<UserDto>> getUserDetails(
@AuthenticationPrincipal PrincipleDetails principle
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ public String getSecret() {
}

@Override
public Long getExpirationInSeconds() {
public Long getAccessExpirationInSeconds() {
return 3600L; // 1시간
}

@Override
public Date getExpirationDate(Date now) {
return new Date(now.getTime() + getExpirationInSeconds() * 1000);
public Date getAccessExpirationDate(Date now) {
return new Date(now.getTime() + getAccessExpirationInSeconds() * 1000);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,20 @@ void testCreateToken() {
Date createdAt = new Date();

// When
String token = jwtComponent.create(userId, role, createdAt);
String token = jwtComponent.createAccessToken(userId, role, createdAt);

// Then
assertNotNull(token);
}

@Test
void testVerifyToken() {
void testVerifyAccessTokenToken() {
// Given
Claims claims = Claims.of(12345789L, "USER", "test-issuer", new Date());
String token = jwtComponent.create(claims);
String token = jwtComponent.createAccessToken(claims);

// When
Claims verifiedClaims = jwtComponent.verify(token);
Claims verifiedClaims = jwtComponent.verifyAccessToken(token);

// Then
assertAll("Verify decoded claims",
Expand All @@ -50,12 +50,12 @@ void testVerifyToken() {
}

@Test
void testVerifyInvalidToken() {
void testVerifyAccessTokenInvalidToken() {
// Given
String invalidToken = "invalid-token";

// When & Then
assertThatThrownBy(() -> jwtComponent.verify(invalidToken))
assertThatThrownBy(() -> jwtComponent.verifyAccessToken(invalidToken))
.isInstanceOf(com.auth0.jwt.exceptions.JWTVerificationException.class);
}
}

0 comments on commit 51688df

Please sign in to comment.