Skip to content

Commit

Permalink
feat: 소셜 로그인 구현 (#15)
Browse files Browse the repository at this point in the history
* chore: rest docs 의존성 제거

* refactor: 패키지 구조 변경

* docs: .env 제외

* feat: 커스텀 유저 서비스 구현

* feat: 프로퍼티 세팅 및 JWT 설정 추가

* feat: 쿠키 유틸리티 구현

* feat: 소셜 로그인 성공 시 토큰 발급해주는 핸들러 구현

* feat: 시큐리티 관련 상수 클래스 추가

* refactor: 토큰 프로퍼티 객체 구조 개선

* feat: 토큰 생성 유틸리티 구현

* feat: 소셜 로그인 성공 핸들러에 토큰 발급 로직 추가

* docs: JWT 유틸리티에 주석 추가

* chore: 레디스 관련 세팅

* feat: 레디스 리프레시 토큰 구현

* refactor: 토큰 전송 시 DTO 사용하도록 리팩토링

* style: spotless 적용

* feat: JwtService 구현 및 적용

* refactor: TokenType을 JwtConstant로 리팩토링

* feat: JwtService 토큰 파싱 및 조회 로직 구현

* refactor: 쿠키 maxAge 제거

* feat: PrincipalDetails 구현

* feat: JWT 파싱 로직 추가

* feat: 엑세스 토큰 만료 체크 로직 추가

* feat: 로그 제거 및 파싱 로직 수정

* feat: 엑세스 토큰 재발급 로직 추가

* feat: JWT 필터 구현

* chore: 시큐리티 설정 추가

* chore: 레디스 컨테이너 설정

* test: 레디스 테스트 설정 추가

* chore: 테스트용 임시 값 추가

* refactor: setter로 변경

* fix: 생성자 사용하도록 수정

* chore: redis 컨테이너 설정 추가

* chore: 레디스 만료 이벤트 수신하지 않도록 변경

* style: spotless 적용

* chore: setup-java 버전 변경

* refactor: 사용하지 않는 메서드 및 의존성 제거
  • Loading branch information
uwoobeat authored Jan 28, 2024
1 parent 0431cf3 commit b4bd4fb
Show file tree
Hide file tree
Showing 32 changed files with 787 additions and 32 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/develop_build_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ jobs:
- name: Run chmod to make gradlew executable
run: chmod +x ./gradlew

# Redis 컨테이너 실행
- name: Start containers
run: docker-compose -f ./docker-compose-test.yaml up -d

# Gradle 빌드
- name: Build with Gradle
id: gradle
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/pull_request_gradle_build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ jobs:
uses: actions/[email protected]

- name: JDK 설치
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17

- name: gradlew 권한 부여
run: chmod +x ./gradlew

# Redis 컨테이너 실행
- name: Start containers
run: docker-compose -f ./docker-compose-test.yaml up -d

- name: Gradle Build
uses: gradle/gradle-build-action@v2
with:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,6 @@ out/

### ETC ###
.DS_Store

### Secrets ###
.env
15 changes: 12 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,26 @@ ext {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

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

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}

tasks.named('test') {
Expand Down
9 changes: 9 additions & 0 deletions docker-compose-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
version: "3.8"

services:
redis:
image: "redis:alpine"
ports:
- "6379:6379"
environment:
- TZ=Asia/Seoul
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,11 @@ services:
- .env
environment:
- TZ=Asia/Seoul
redis:
image: "redis:alpine"
container_name: redis
ports:
- "6379:6379"
environment:
- TZ=Asia/Seoul
network_mode: host

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.gdschongik.gdsc.domain.auth.application;

import static com.gdschongik.gdsc.global.common.constant.SecurityConstant.*;

import com.gdschongik.gdsc.domain.auth.dao.RefreshTokenRepository;
import com.gdschongik.gdsc.domain.auth.domain.RefreshToken;
import com.gdschongik.gdsc.domain.auth.dto.AccessTokenDto;
import com.gdschongik.gdsc.domain.auth.dto.RefreshTokenDto;
import com.gdschongik.gdsc.domain.member.domain.MemberRole;
import com.gdschongik.gdsc.global.util.JwtUtil;
import io.jsonwebtoken.ExpiredJwtException;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class JwtService {

private final JwtUtil jwtUtil;
private final RefreshTokenRepository refreshTokenRepository;

public AccessTokenDto createAccessToken(Long memberId, MemberRole memberRole) {
return jwtUtil.generateAccessToken(memberId, memberRole);
}

public RefreshTokenDto createRefreshToken(Long memberId) {
RefreshTokenDto refreshTokenDto = jwtUtil.generateRefreshToken(memberId);
saveRefreshTokenToRedis(refreshTokenDto);
return refreshTokenDto;
}

private void saveRefreshTokenToRedis(RefreshTokenDto refreshTokenDto) {
RefreshToken refreshToken = RefreshToken.builder()
.memberId(refreshTokenDto.memberId())
.token(refreshTokenDto.tokenValue())
.ttl(refreshTokenDto.ttl())
.build();
refreshTokenRepository.save(refreshToken);
}

public AccessTokenDto retrieveAccessToken(String accessTokenValue) {
try {
return jwtUtil.parseAccessToken(accessTokenValue);
} catch (Exception e) {
return null;
}
}

public RefreshTokenDto retrieveRefreshToken(String refreshTokenValue) {
RefreshTokenDto refreshTokenDto = parseRefreshToken(refreshTokenValue);

if (refreshTokenDto == null) {
return null;
}

// 파싱된 DTO와 일치하는 토큰이 Redis에 저장되어 있는지 확인
Optional<RefreshToken> refreshToken = getRefreshTokenFromRedis(refreshTokenDto.memberId());

// Redis에 토큰이 존재하고, 쿠키의 토큰과 값이 일치하면 DTO 반환
if (refreshToken.isPresent()
&& refreshTokenDto.tokenValue().equals(refreshToken.get().getToken())) {
return refreshTokenDto;
}

// Redis에 토큰이 존재하지 않거나, 쿠키의 토큰과 값이 일치하지 않으면 null 반환
return null;
}

private Optional<RefreshToken> getRefreshTokenFromRedis(Long memberId) {
// TODO: CustomException으로 바꾸기
return refreshTokenRepository.findByMemberId(memberId);
}

private RefreshTokenDto parseRefreshToken(String refreshTokenValue) {
try {
return jwtUtil.parseRefreshToken(refreshTokenValue);
} catch (Exception e) {
return null;
}
}

public AccessTokenDto reissueAccessTokenIfExpired(String accessTokenValue) {
// AT가 만료된 경우 AT를 재발급, 만료되지 않은 경우 null 반환
try {
jwtUtil.parseAccessToken(accessTokenValue);
return null;
} catch (ExpiredJwtException e) {
Long memberId = Long.parseLong(e.getClaims().getSubject());
MemberRole memberRole = MemberRole.valueOf(e.getClaims().get(TOKEN_ROLE_NAME, String.class));
return createAccessToken(memberId, memberRole);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.gdschongik.gdsc.domain.auth.dao;

import com.gdschongik.gdsc.domain.auth.domain.RefreshToken;
import java.util.Optional;
import org.springframework.data.repository.CrudRepository;

public interface RefreshTokenRepository extends CrudRepository<RefreshToken, Long> {
Optional<RefreshToken> findByMemberId(Long aLong);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.gdschongik.gdsc.domain.auth.domain;

import lombok.Builder;
import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;

@Getter
@RedisHash(value = "refreshToken")
public class RefreshToken {

@Id
private Long memberId;

private String token;

@TimeToLive
private long ttl;

@Builder
public RefreshToken(Long memberId, String token, long ttl) {
this.memberId = memberId;
this.token = token;
this.ttl = ttl;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.gdschongik.gdsc.domain.auth.dto;

import com.gdschongik.gdsc.domain.member.domain.MemberRole;

public record AccessTokenDto(Long memberId, MemberRole memberRole, String tokenValue) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.gdschongik.gdsc.domain.auth.dto;

public record RefreshTokenDto(Long memberId, String tokenValue, Long ttl) {}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.gdschongik.gdsc.common.model;
package com.gdschongik.gdsc.domain.common.model;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.gdschongik.gdsc.domain.member.dao;

import com.gdschongik.gdsc.domain.member.domain.Member;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {

Optional<Member> findByOauthId(String oauthId);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.gdschongik.gdsc.domain.member.domain;

import com.gdschongik.gdsc.common.model.BaseTimeEntity;
import com.gdschongik.gdsc.domain.common.model.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.gdschongik.gdsc.global.common.constant;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum JwtConstant {
ACCESS_TOKEN(Constants.ACCESS_TOKEN_COOKIE_NAME),
REFRESH_TOKEN(Constants.REFRESH_TOKEN_COOKIE_NAME);

private final String cookieName;

private static class Constants {
public static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken";
public static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.gdschongik.gdsc.global.common.constant;

public class SecurityConstant {

public static final String REGISTRATION_REQUIRED_HEADER = "Registration-Required";
public static final String TOKEN_ROLE_NAME = "role";

private SecurityConstant() {}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.gdschongik.gdsc.common.config;
package com.gdschongik.gdsc.global.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.gdschongik.gdsc.global.config;

import com.gdschongik.gdsc.global.property.JwtProperty;
import com.gdschongik.gdsc.global.property.RedisProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@EnableConfigurationProperties({JwtProperty.class, RedisProperty.class})
@Configuration
public class PropertyConfig {}
35 changes: 35 additions & 0 deletions src/main/java/com/gdschongik/gdsc/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.gdschongik.gdsc.global.config;

import com.gdschongik.gdsc.global.property.RedisProperty;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;

@RequiredArgsConstructor
@Configuration
public class RedisConfig {

private final RedisProperty redisProperty;

@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration redisConfig =
new RedisStandaloneConfiguration(redisProperty.getHost(), redisProperty.getPort());

if (!redisProperty.getPassword().isBlank()) {
redisConfig.setPassword(redisProperty.getPassword());
}

LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofSeconds(1))
.shutdownTimeout(Duration.ZERO)
.build();

return new LettuceConnectionFactory(redisConfig, clientConfig);
}
}
Loading

0 comments on commit b4bd4fb

Please sign in to comment.