Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ implement refresh token #113

Merged
merged 12 commits into from
Jul 31, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import net.pengcook.authentication.dto.GoogleLoginResponse;
import net.pengcook.authentication.dto.GoogleSignUpRequest;
import net.pengcook.authentication.dto.GoogleSignUpResponse;
import net.pengcook.authentication.dto.TokenRefreshRequest;
import net.pengcook.authentication.dto.TokenRefreshResponse;
import net.pengcook.authentication.service.LoginService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
Expand All @@ -29,4 +31,9 @@ public GoogleLoginResponse loginWithGoogle(@RequestBody @Valid GoogleLoginReques
public GoogleSignUpResponse sighUpWithGoogle(@RequestBody @Valid GoogleSignUpRequest googleSignUpRequest) {
return LoginService.signUpWithGoogle(googleSignUpRequest);
}

@PostMapping("/api/token/refresh")
public TokenRefreshResponse refresh(@RequestBody @Valid TokenRefreshRequest tokenRefreshRequest) {
return LoginService.refresh(tokenRefreshRequest.refreshToken());
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
package net.pengcook.authentication.util;
package net.pengcook.authentication.domain;

import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.util.Date;
import net.pengcook.authentication.dto.TokenPayload;
import net.pengcook.authentication.exception.JwtTokenException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
Expand All @@ -15,25 +14,30 @@
public class JwtTokenManager {

private static final String CLAIM_EMAIL = "email";
private static final String CLAIM_TOKEN_TYPE = "tokenType";

private final Algorithm secretAlgorithm;
private final long tokenExpirationMills;
private final long accessTokenExpirationMills;
private final long refreshTokenExpirationMills;

public JwtTokenManager(
@Value("${jwt.secret}") String secret,
@Value("${jwt.expire-in-millis}") long tokenExpirationMills
@Value("${jwt.access-token.expire-in-millis}") long accessTokenExpirationMills,
@Value("${jwt.refresh-token.expire-in-millis}") long refreshTokenExpirationMills
) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

properties 에 코멘트 남겨놓은 것을 추가하면 여기에 타입으로 long 이 아닌 Duration 을 줄 수 있을 것 같아요!

this.secretAlgorithm = Algorithm.HMAC512(secret);
this.tokenExpirationMills = tokenExpirationMills;
this.accessTokenExpirationMills = accessTokenExpirationMills;
this.refreshTokenExpirationMills = refreshTokenExpirationMills;
}

public String createToken(TokenPayload payload) {
Date issuedAt = new Date(System.currentTimeMillis());
Date expiresAt = new Date(System.currentTimeMillis() + tokenExpirationMills);
Date expiresAt = getExpiresAt(payload);

return JWT.create()
.withSubject(String.valueOf(payload.userId()))
.withClaim(CLAIM_EMAIL, payload.email())
.withClaim(CLAIM_TOKEN_TYPE, payload.tokenType().name())
.withIssuedAt(issuedAt)
.withExpiresAt(expiresAt)
.sign(secretAlgorithm);
Expand All @@ -49,10 +53,18 @@ public TokenPayload extract(String token) {
}
}

private Date getExpiresAt(TokenPayload payload) {
if (payload.tokenType() == TokenType.REFRESH) {
return new Date(System.currentTimeMillis() + refreshTokenExpirationMills);
}
return new Date(System.currentTimeMillis() + accessTokenExpirationMills);
}

private TokenPayload getTokenPayload(DecodedJWT decodedJWT) {
long userId = Long.parseLong(decodedJWT.getSubject());
String email = decodedJWT.getClaim(CLAIM_EMAIL).asString();
TokenType tokenType = TokenType.valueOf(decodedJWT.getClaim(CLAIM_TOKEN_TYPE).asString());

return new TokenPayload(userId, email);
return new TokenPayload(userId, email, tokenType);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package net.pengcook.authentication.util;
package net.pengcook.authentication.domain;

import net.pengcook.authentication.exception.AuthorizationHeaderException;
import org.springframework.stereotype.Component;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package net.pengcook.authentication.domain;

import net.pengcook.authentication.exception.JwtTokenException;

public record TokenPayload(
long userId,
String email,
TokenType tokenType
) {

public void validateAccessToken(String message) {
if (tokenType != TokenType.ACCESS) {
throw new JwtTokenException(message);
}
}

public void validateRefreshToken(String message) {
if (tokenType != TokenType.REFRESH) {
throw new JwtTokenException(message);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package net.pengcook.authentication.domain;

public enum TokenType {

ACCESS, REFRESH,
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

public record GoogleLoginResponse(
boolean registered,
String accessToken
String accessToken,
String refreshToken
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ public record GoogleSignUpResponse(
String image,
String birthday,
String country,
String accessToken
String accessToken,
String refreshToken
) {
public GoogleSignUpResponse(User user, String accessToken) {
public GoogleSignUpResponse(User user, String accessToken, String refreshToken) {
this(
user.getId(),
user.getEmail(),
Expand All @@ -21,7 +22,8 @@ public GoogleSignUpResponse(User user, String accessToken) {
user.getImage(),
user.getBirth().toString(),
user.getRegion(),
accessToken
accessToken,
refreshToken
);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package net.pengcook.authentication.dto;

import jakarta.validation.constraints.NotBlank;

public record TokenRefreshRequest(@NotBlank String refreshToken) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package net.pengcook.authentication.dto;

public record TokenRefreshResponse(
String accessToken,
String refreshToken
) {
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package net.pengcook.authentication.resolver;

import lombok.AllArgsConstructor;
import net.pengcook.authentication.domain.JwtTokenManager;
import net.pengcook.authentication.domain.TokenExtractor;
import net.pengcook.authentication.domain.TokenPayload;
import net.pengcook.authentication.domain.UserInfo;
import net.pengcook.authentication.dto.TokenPayload;
import net.pengcook.authentication.util.JwtTokenManager;
import net.pengcook.authentication.util.TokenExtractor;
import org.springframework.core.MethodParameter;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
Expand All @@ -27,7 +27,7 @@ public boolean supportsParameter(MethodParameter parameter) {
}

@Override
public Object resolveArgument(
public UserInfo resolveArgument(
MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
Expand All @@ -38,6 +38,7 @@ public Object resolveArgument(
String accessToken = tokenExtractor.extractToken(authorizationHeader);

TokenPayload tokenPayload = jwtTokenManager.extract(accessToken);
tokenPayload.validateAccessToken("헤더에 토큰이 access token이 아닙니다.");

return new UserInfo(tokenPayload.userId(), tokenPayload.email());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
import com.google.firebase.auth.FirebaseAuthException;
import com.google.firebase.auth.FirebaseToken;
import lombok.AllArgsConstructor;
import net.pengcook.authentication.domain.JwtTokenManager;
import net.pengcook.authentication.domain.TokenPayload;
import net.pengcook.authentication.domain.TokenType;
import net.pengcook.authentication.dto.GoogleLoginRequest;
import net.pengcook.authentication.dto.GoogleLoginResponse;
import net.pengcook.authentication.dto.GoogleSignUpRequest;
import net.pengcook.authentication.dto.GoogleSignUpResponse;
import net.pengcook.authentication.dto.TokenPayload;
import net.pengcook.authentication.dto.TokenRefreshResponse;
import net.pengcook.authentication.exception.DuplicationException;
import net.pengcook.authentication.exception.FirebaseTokenException;
import net.pengcook.authentication.util.JwtTokenManager;
import net.pengcook.user.domain.User;
import net.pengcook.user.repository.UserRepository;
import org.springframework.stereotype.Service;
Expand All @@ -29,12 +31,13 @@ public GoogleLoginResponse loginWithGoogle(GoogleLoginRequest googleLoginRequest
String email = decodedToken.getEmail();

if (!userRepository.existsByEmail(email)) {
return new GoogleLoginResponse(false, null);
return new GoogleLoginResponse(false, null, null);
}
User user = userRepository.findByEmail(email);
String accessToken = jwtTokenManager.createToken(new TokenPayload(user.getId(), user.getEmail()));
String accessToken = jwtTokenManager.createToken(new TokenPayload(user.getId(), user.getEmail(), TokenType.ACCESS));
String refreshToken = jwtTokenManager.createToken(new TokenPayload(user.getId(), user.getEmail(), TokenType.REFRESH));

return new GoogleLoginResponse(true, accessToken);
return new GoogleLoginResponse(true, accessToken, refreshToken);
}

public GoogleSignUpResponse signUpWithGoogle(GoogleSignUpRequest googleSignUpRequest) {
Expand All @@ -45,9 +48,22 @@ public GoogleSignUpResponse signUpWithGoogle(GoogleSignUpRequest googleSignUpReq
}

User savedUser = userRepository.save(user);
String accessToken = jwtTokenManager.createToken(new TokenPayload(savedUser.getId(), savedUser.getEmail()));
String accessToken = jwtTokenManager.createToken(
new TokenPayload(savedUser.getId(), savedUser.getEmail(), TokenType.ACCESS));
String refreshToken = jwtTokenManager.createToken(
new TokenPayload(savedUser.getId(), savedUser.getEmail(), TokenType.REFRESH));

return new GoogleSignUpResponse(savedUser, accessToken);
return new GoogleSignUpResponse(savedUser, accessToken, refreshToken);
}

public TokenRefreshResponse refresh(String refreshToken) {
TokenPayload tokenPayload = jwtTokenManager.extract(refreshToken);
tokenPayload.validateRefreshToken("refresh token이 아닙니다.");

return new TokenRefreshResponse(
jwtTokenManager.createToken(new TokenPayload(tokenPayload.userId(), tokenPayload.email(), TokenType.ACCESS)),
jwtTokenManager.createToken(tokenPayload)
);
}

private User createUser(GoogleSignUpRequest googleSignUpRequest) {
Expand Down
1 change: 0 additions & 1 deletion backend/src/main/resources/application-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ spring:

jwt:
secret: ENC(+tXNIDNrGAWDx/vMv+AO5KkhPG5iY/mkjagusXWcO16RVHEBI9rq40GukA/RU6X7mEcBPYgWHhlmA5ql6XQU349AIhsJ6Hd3g9f4FD4zcvkHDmJtRV8CAvr9XaMQbtSvCRWMoipYKbTFs5SobwGO1xWntcFIX8LCWxM0wi58Hzz4eJvo7naZiSg3Hqp39zH0pAR8LRP5qbhfyFKH7pIX6AB7q2SqYJIre3T7JIeU5U4=)
expire-in-millis: 86400000

firebase:
service-account: ENC(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)
Expand Down
1 change: 0 additions & 1 deletion backend/src/main/resources/application-local.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ spring:

jwt:
secret: ENC(PMp06UdOJEXk9e7bpjHZunoeLhFdeTqCRRrn/WZ2xBnGvd9+eIAe2TCCRBgBEH9pGwfDCIhnZeHeEs4ownlWCeGOKEhrl5wpMkJX8ycus7kqlR6XYREK8rDGDH2jPwQtda97ZQ6Mk6nJ00x9Sx6tZts4z0OnbRkgPDCPhQV6M3TJpdJHFeYmYq6b+5wGVcnBA/9CVm1FGa/d0LPbtg2fAJ/mVpIer0VhTl/hUmUvjPI=)
expire-in-millis: 86400000

firebase:
service-account: ENC(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)
Expand Down
Loading
Loading