Skip to content
This repository has been archived by the owner on Jul 29, 2024. It is now read-only.

Commit

Permalink
[BE] feat: JWT 정책 구현 (#256)
Browse files Browse the repository at this point in the history
* feat: jwt 의존성 추가

Co-authored-by: HubCreator <[email protected]>

* feat: Jwt 토큰 발급 기능 구현

Co-authored-by: HubCreator <[email protected]>

* feat: `Token` Entity 생성

Co-authored-by: HubCreator <[email protected]>

* refactor: `member` 정적 팩토리 메서드 입력값 수정

Co-authored-by: HubCreator <[email protected]>

* feat: JWT 정책 추가

Co-authored-by: HubCreator <[email protected]>

* feat: 인터셉터 초안

Co-authored-by: HubCreator <[email protected]>

* refactor: 사용하지 않는 클래스 삭제

Co-authored-by: HubCreator <[email protected]>

* feat: token을 반환하는 Extractor기능 구현

Co-authored-by: HubCreator <[email protected]>

* feat: token의 대한 CustomException 정의

Co-authored-by: HubCreator <[email protected]>

* feat: 인증에 사용되는 커스텀 어노테이션 정의

Co-authored-by: HubCreator <[email protected]>

* feat: access와 refresh 토큰 Interceptor 기능 구현

Co-authored-by: HubCreator <[email protected]>

* feat: refreshToken의 유효성 검사 로직 기능 구현

Co-authored-by: HubCreator <[email protected]>

* feat: 토큰 검증 후 AccessToken 발급 기능 구현

Co-authored-by: HubCreator <[email protected]>

* refactor: token의 payload key값 변경

Co-authored-by: HubCreator <[email protected]>

* feat: accessToken Resolver기능 구현

Co-authored-by: HubCreator <[email protected]>

* refactor: 패키지 변경 및 DTO생성

Co-authored-by: HubCreator <[email protected]>

* refactor: test 코드 수정

Co-authored-by: HubCreator <[email protected]>

* feat: 인가 어노테이션 적용

Co-authored-by: HubCreator <[email protected]>

* feat: token의 대한 환경변수 추가

Co-authored-by: HubCreator <[email protected]>

* refactor :환경변수 추가 및 개행 제거

Co-authored-by: HubCreator <[email protected]>

* refactor :Response타입 변경

Co-authored-by: HubCreator <[email protected]>

* refactor :docker 설정 및 환경변수 수정

* refactor :재발급 기준 변경

* refactor :config 결합 및 Interceptor범위 수정

* refactor :네이밍 수정

* refactor :interceptor범위 수정

* refactor :token을 return방식 수정

* refactor :사용하지 않는 어노테이션 삭제

* refactor :사용하지 않는 파라미터 삭제

* refactor :Cookie 유효시간 생성자 주입 적용

* refactor :Path범위 수정

* refactor :중복 되는 로직 메서드 분리

* refactor :접근제어자 변경

* refactor :long -> int 변경

* refactor :에러 메시지 오타 수정

* refactor :개행 제거

* refactor :불필요한 if문 제거

---------

Co-authored-by: HubCreator <[email protected]>
Co-authored-by: echo724 <[email protected]>
  • Loading branch information
3 people authored Aug 14, 2023
1 parent 0916f23 commit abd353b
Show file tree
Hide file tree
Showing 31 changed files with 563 additions and 57 deletions.
15 changes: 9 additions & 6 deletions .github/workflows/deploy-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ jobs:
echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> .env
echo "SPRING_PROFILES_ACTIVE=${{ secrets.SPRING_PROFILES_ACTIVE }}" >> .env
echo "PROFILE=${{ secrets.DEV_PROFILE }}" >> .env
echo "KAKAO_CLIENT_ID"=${{ secrets.KAKAO_CLIENT_ID }} >> .env
echo "KAKAO_CLIENT_SECRET"=${{ secrets.KAKAO_CLIENT_SECRET }} >> .env
echo "TISTORY_CLIENT_ID"=${{ secrets.TISTORY_CLIENT_ID }} >> .env
echo "TISTORY_CLIENT_SECRET"=${{ secrets.TISTORY_CLIENT_SECRET }} >> .env
echo "NOTION_CLIENT_ID"=${{ secrets.NOTION_CLIENT_ID }} >> .env
echo "NOTION_CLIENT_SECRET"=${{ secrets.NOTION_CLIENT_SECRET }} >> .env
echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> .env
echo "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" >> .env
echo "TISTORY_CLIENT_ID=${{ secrets.TISTORY_CLIENT_ID }}" >> .env
echo "TISTORY_CLIENT_SECRET=${{ secrets.TISTORY_CLIENT_SECRET }}" >> .env
echo "NOTION_CLIENT_ID=${{ secrets.NOTION_CLIENT_ID }}" >> .env
echo "NOTION_CLIENT_SECRET=${{ secrets.NOTION_CLIENT_SECRET }}" >> .env
echo "JWT_SECRET_KET=${{ secrets.JWT_SECRET_KEY }}" >> .env
echo "ACCESS_TOKEN_EXPIRE=${{ secrets.ACCESS_TOKEN_EXPIRE_LENGTH }}" >> .env
echo "REFRESH_TOKEN_EXPIRE_LENGTH=${{ secrets.REFRESH_TOKEN_EXPIRE_LENGTH }}" >> .env
## deploy to production
- name: Deploy to prod
run: |
Expand Down
17 changes: 10 additions & 7 deletions .github/workflows/deploy-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@ jobs:
echo "DB_USERNAME=${{ secrets.DB_USERNAME_PROD }}" >> .env
echo "DB_PASSWORD=${{ secrets.DB_PASSWORD_PROD }}" >> .env
echo "SPRING_PROFILES_ACTIVE=${{ secrets.SPRING_PROFILES_ACTIVE }}" >> .env
echo "PROFILE"=${{ secrets.PROD_PROFILE }} >> .env
echo "KAKAO_CLIENT_ID"=${{ secrets.KAKAO_CLIENT_ID }} >> .env
echo "KAKAO_CLIENT_SECRET"=${{ secrets.KAKAO_CLIENT_SECRET }} >> .env
echo "TISTORY_CLIENT_ID"=${{ secrets.TISTORY_CLIENT_ID }} >> .env
echo "TISTORY_CLIENT_SECRET"=${{ secrets.TISTORY_CLIENT_SECRET }} >> .env
echo "NOTION_CLIENT_ID"=${{ secrets.NOTION_CLIENT_ID }} >> .env
echo "NOTION_CLIENT_SECRET"=${{ secrets.NOTION_CLIENT_SECRET }} >> .env
echo "PROFILE=${{ secrets.PROD_PROFILE }}" >> .env
echo "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" >> .env
echo "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" >> .env
echo "TISTORY_CLIENT_ID=${{ secrets.TISTORY_CLIENT_ID }}" >> .env
echo "TISTORY_CLIENT_SECRET=${{ secrets.TISTORY_CLIENT_SECRET }}" >> .env
echo "NOTION_CLIENT_ID=${{ secrets.NOTION_CLIENT_ID }}" >> .env
echo "NOTION_CLIENT_SECRET=${{ secrets.NOTION_CLIENT_SECRET }}" >> .env
echo "JWT_SECRET_KET=${{ secrets.JWT_SECRET_KEY }}" >> .env
echo "ACCESS_TOKEN_EXPIRE=${{ secrets.ACCESS_TOKEN_EXPIRE_LENGTH }}" >> .env
echo "REFRESH_TOKEN_EXPIRE_LENGTH=${{ secrets.REFRESH_TOKEN_EXPIRE_LENGTH }}" >> .env
## deploy to production
- name: Deploy to prod
run: |
Expand Down
4 changes: 4 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'mysql:mysql-connector-java:8.0.28'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.donggle.backend.application.repository;

import org.donggle.backend.auth.JwtToken;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface TokenRepository extends JpaRepository<JwtToken, Long> {
Optional<JwtToken> findByMemberId(final Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,44 @@
import lombok.RequiredArgsConstructor;
import org.donggle.backend.application.repository.MemberRepository;
import org.donggle.backend.application.service.oauth.kakao.dto.KakaoProfileResponse;
import org.donggle.backend.auth.JwtTokenProvider;
import org.donggle.backend.auth.JwtTokenService;
import org.donggle.backend.auth.exception.NoSuchTokenException;
import org.donggle.backend.domain.member.Member;
import org.donggle.backend.domain.member.MemberName;
import org.donggle.backend.ui.response.TokenResponse;
import org.springframework.stereotype.Service;

@Service
@Transactional
@RequiredArgsConstructor
public class AuthService {
private final MemberRepository memberRepository;
private final JwtTokenProvider jwtTokenProvider;
private final JwtTokenService jwtTokenService;

public void loginByKakao(final KakaoProfileResponse kakaoProfileResponse) {
public TokenResponse loginByKakao(final KakaoProfileResponse kakaoProfileResponse) {
final Member loginMember = memberRepository.findByKakaoId(kakaoProfileResponse.id())
.orElseGet(() -> memberRepository.save(Member.createByKakao(kakaoProfileResponse)));
.orElseGet(() -> memberRepository.save(Member.createByKakao(
new MemberName(kakaoProfileResponse.getNickname()),
kakaoProfileResponse.id())
));
return createTokens(loginMember);
}

public TokenResponse reissueAccessTokenAndRefreshToken(final Long memberId) {
final Member member = memberRepository.findById(memberId).
orElseThrow(NoSuchTokenException::new);

return createTokens(member);
}

private TokenResponse createTokens(final Member loginMember) {
final String accessToken = jwtTokenProvider.createAccessToken(loginMember.getId());
final String refreshToken = jwtTokenProvider.createRefreshToken(loginMember.getId());

jwtTokenService.synchronizeRefreshToken(loginMember, refreshToken);

return new TokenResponse(accessToken, refreshToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.donggle.backend.application.service.oauth.kakao.dto.KakaoProfileResponse;
import org.donggle.backend.application.service.oauth.kakao.dto.KakaoTokenResponse;
import org.donggle.backend.application.service.request.OAuthAccessTokenRequest;
import org.donggle.backend.ui.response.TokenResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
Expand Down Expand Up @@ -45,10 +46,10 @@ public String createAuthorizeRedirectUri(final String redirectUri) {
.toUriString();
}

public void login(final OAuthAccessTokenRequest oAuthAccessTokenRequest) {
public TokenResponse login(final OAuthAccessTokenRequest oAuthAccessTokenRequest) {
final String accessToken = requestAccessToken(oAuthAccessTokenRequest);
final KakaoProfileResponse kakaoProfileResponse = requestKakaoProfile(accessToken);
authService.loginByKakao(kakaoProfileResponse);
return authService.loginByKakao(kakaoProfileResponse);
}

private String requestAccessToken(final OAuthAccessTokenRequest oAuthAccessTokenRequest) {
Expand Down
65 changes: 65 additions & 0 deletions backend/src/main/java/org/donggle/backend/auth/JwtToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package org.donggle.backend.auth;

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToOne;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.donggle.backend.domain.member.Member;

import java.util.Objects;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class JwtToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String refreshToken;
@OneToOne(fetch = FetchType.LAZY)
private Member member;

public JwtToken(final String token, final Member member) {
this.refreshToken = token;
this.member = member;
}

public boolean isDifferentRefreshToken(final String refreshToken) {
return !this.refreshToken.equals(refreshToken);
}

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

@Override
public String toString() {
return "JwtToken{" +
"id=" + id +
", token='" + refreshToken + '\'' +
", member=" + member +
'}';
}

@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final JwtToken jwtToken = (JwtToken) o;
return Objects.equals(getId(), jwtToken.getId());
}

@Override
public int hashCode() {
return Objects.hash(getId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package org.donggle.backend.auth;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.donggle.backend.auth.exception.NoSuchTokenException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;

@Component
public class JwtTokenProvider {
private static final String MEMBER_ID_KEY = "memberId";

private final SecretKey key;
private final long accessTokenValidityInMilliseconds;
private final long refreshTokenValidityInMilliseconds;

public JwtTokenProvider(@Value("${security.jwt.token.secret-key}") final String secretKey,
@Value("${security.jwt.token.access-token-expire-length}") final int accessTokenValidityInMilliseconds,
@Value("${security.jwt.token.refresh-token-expire-length}") final int refreshTokenValidityInMilliseconds) {
this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds;
this.refreshTokenValidityInMilliseconds = refreshTokenValidityInMilliseconds;
}

public String createAccessToken(final Long payload) {
return createToken(payload, accessTokenValidityInMilliseconds);
}

public String createRefreshToken(final Long payload) {
return createToken(payload, refreshTokenValidityInMilliseconds);
}

private String createToken(final Long payload, final long validityInMilliseconds) {
final Date now = new Date();
final Date validity = new Date(now.getTime() + validityInMilliseconds);

return Jwts.builder()
.claim(MEMBER_ID_KEY, payload)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}

public Long getPayload(final String token) {
return getClaims(token).getBody().get(MEMBER_ID_KEY, Long.class);
}

public boolean inValidTokenUsage(final String token) {
try {
final Jws<Claims> claims = getClaims(token);
return claims.getBody().getExpiration().before(new Date());
} catch (final ExpiredJwtException e) {
throw new NoSuchTokenException();
} catch (final JwtException | IllegalArgumentException e) {
return true;
}
}

private Jws<Claims> getClaims(final String token) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.donggle.backend.auth;

import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.donggle.backend.application.repository.TokenRepository;
import org.donggle.backend.domain.member.Member;
import org.springframework.stereotype.Service;

@Service
@Transactional
@RequiredArgsConstructor
public class JwtTokenService {
private final TokenRepository tokenRepository;

public void synchronizeRefreshToken(final Member member, final String refreshToken) {
tokenRepository.findByMemberId(member.getId())
.ifPresentOrElse(
token -> token.updateRefreshToken(refreshToken),
() -> tokenRepository.save(new JwtToken(refreshToken, member))
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.donggle.backend.auth.exception;

import org.donggle.backend.exception.business.BusinessException;

public class EmptyAuthorizationHeaderException extends BusinessException {
private static final String MESSAGE = "header에 Authorization이 존재하지 않습니다.";

public EmptyAuthorizationHeaderException() {
super(MESSAGE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.donggle.backend.auth.exception;

import org.donggle.backend.exception.business.BusinessException;

public class InvalidAccessTokenException extends BusinessException {
private static final String MESSAGE = "유효하지 않은 토큰입니다.";

public InvalidAccessTokenException() {
super(MESSAGE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.donggle.backend.auth.exception;

import org.donggle.backend.exception.business.BusinessException;

public class NoSuchTokenException extends BusinessException {
private static final String MESSAGE = "존재하지 않는 토큰입니다.";

public NoSuchTokenException() {
super(MESSAGE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.donggle.backend.auth.presentation;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.donggle.backend.auth.JwtTokenProvider;
import org.donggle.backend.auth.exception.InvalidAccessTokenException;
import org.donggle.backend.auth.support.AuthorizationExtractor;
import org.springframework.web.cors.CorsUtils;
import org.springframework.web.servlet.HandlerInterceptor;

@RequiredArgsConstructor
public class AuthInterceptor implements HandlerInterceptor {
private final JwtTokenProvider jwtTokenProvider;

@Override
public boolean preHandle(final HttpServletRequest request, final HttpServletResponse response, final Object handler) {
if (CorsUtils.isPreFlightRequest(request)) {
return true;
}

validateToken(request);
return true;
}

private void validateToken(final HttpServletRequest request) {
final String token = AuthorizationExtractor.extract(request);
if (jwtTokenProvider.inValidTokenUsage(token)) {
throw new InvalidAccessTokenException();
}
}
}
Loading

0 comments on commit abd353b

Please sign in to comment.