Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,30 @@ 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'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'

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

// jwt
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

// Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'

// Apple 로그인 연동시 필요한 설정
implementation group: 'com.nimbusds', name: 'nimbus-jose-jwt', version: '9.30.1'
implementation group: 'org.bouncycastle', name: 'bcpkix-jdk15on', version: '1.70'
implementation 'org.apache.directory.studio:org.apache.commons.io:2.4'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
package dorun.project.routineapp;

import dorun.project.routineapp.config.properties.AppProperties;
import dorun.project.routineapp.config.properties.CorsProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

@SpringBootApplication
@EnableConfigurationProperties({
CorsProperties.class,
AppProperties.class
})
public class RoutineAppApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package dorun.project.routineapp.api.controller;

import dorun.project.routineapp.api.entity.RefreshToken;
import dorun.project.routineapp.common.APIResponse;
import dorun.project.routineapp.config.properties.AppProperties;
import dorun.project.routineapp.oauth.entity.RoleType;
import dorun.project.routineapp.oauth.token.Token;
import dorun.project.routineapp.oauth.token.TokenProvider;
import dorun.project.routineapp.oauth.util.HeaderUtil;
import dorun.project.routineapp.api.repository.RefreshTokenRepository;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthController {
private final AppProperties appProperties;
private final TokenProvider tokenProvider;
private final RefreshTokenRepository refreshTokenRepository;

private static final long UPDATE_TOKEN_STRATEGY = 259200000; // 3일
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

해당 변수를 yaml 파일로 빼는게 좋아보입니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

넵, 동의합니다. 그리고 리프레시 토큰을 레디스에 얼마나 저장할 지도 정해야 합니다. 임의로 3일로 해두었는데, 적당한 기간을 정해봐요.

private static final String ACCESS_TOKEN = "access_token";
private static final String REFRESH_TOKEN = "refresh_token";

@PostMapping("/refresh")
public APIResponse<Map<String, String>> refreshToken(HttpServletRequest request, HttpServletResponse response) {
String accessToken = HeaderUtil.getToken(request, ACCESS_TOKEN);
Token authToken = tokenProvider.convertToken(accessToken);
if (!authToken.validate()) {
return APIResponse.invalidRefreshToken();
}

Claims claims = authToken.getExpiredTokenClaims();
if (claims == null) {
return APIResponse.notExpiredTokenYet();
}

String userId = claims.getSubject();
RoleType roleType = RoleType.of(claims.get("role", String.class));

String refreshToken = HeaderUtil.getToken(request, REFRESH_TOKEN);
Token authRefreshToken = tokenProvider.convertToken(refreshToken);

if (authRefreshToken.validate()) {
return APIResponse.invalidRefreshToken();
}

Optional<RefreshToken> searchedRefreshToken = refreshTokenRepository.findByUserId(userId);
RefreshToken userRefreshToken;
if (searchedRefreshToken.isEmpty()) {
return APIResponse.invalidRefreshToken();
}
userRefreshToken = searchedRefreshToken.get();

Date now = new Date();
Token newAccessToken = tokenProvider.createToken(
userId,
roleType.getCode(),
new Date(now.getTime() + appProperties.getAuth().getTokenExpiry())
);

long validTime = authRefreshToken.getTokenClaims().getExpiration().getTime() - now.getTime();

if (validTime <= UPDATE_TOKEN_STRATEGY) {
long refreshTokenExpiry = appProperties.getAuth().getRefreshTokenExpiry();

authRefreshToken = tokenProvider.createToken(
appProperties.getAuth().getTokenSecret(),
new Date(now.getTime() + refreshTokenExpiry)
);

userRefreshToken.updateRefreshToken(authRefreshToken.getToken());
refreshTokenRepository.save(userRefreshToken);
}

Map<String, String> tokens = new HashMap<>();
tokens.put(ACCESS_TOKEN, newAccessToken.getToken());
tokens.put(REFRESH_TOKEN, userRefreshToken.getRefreshToken());

return APIResponse.success("tokens", tokens);
}
}
24 changes: 24 additions & 0 deletions src/main/java/dorun/project/routineapp/api/entity/BaseEntity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package dorun.project.routineapp.api.entity;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import java.time.LocalDateTime;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

@MappedSuperclass
@Getter
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

-추상클래스인 이유가 있을까요??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

당장은 특별한 이유는 없습니다. 다만 BaseEntity를 상속받는 클래스가 공통적으로 사용하는 메서드가 각 클래스마다 구현 방식이 다른 경우 추상 메서드로 정의해서 사용할 수 있도록 습관적으로 만들어둔 것입니다.

뭔가 명확한 단점이 있다면 일반 클래스로 사용해도 됩니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

오! 그렇군요.
추상클래스로 만든다는 생각을 못해봤는데 생각해보니 사용할 이유가 있는것 같습니다.

  • 추상 클래스로 선언함으로써 BaseEntity를 직접 생성하는 것을 방지
  • 추상 클래스로 만듬으로 해당 클래스의 의미를 명확하게 나타낼 수 있을 것 같다

배워갑니다...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Serializable 을 implements 하고 있는 것도 좋아보입니다!!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

넵 좋은 코멘트입니다. 수정하겠습니다!


@Column(name = "CREATED_AT")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

( updatable = false ) 옵션이 있으면 좋을 것 같습니다

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

이 또한 좋은 코멘트입니다. 수정하겠습니다!

@CreatedDate
private LocalDateTime createdAt;

@Column(name = "MODIFIED_AT")
@LastModifiedDate
private LocalDateTime modifiedAt;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package dorun.project.routineapp.api.entity;

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.index.Indexed;

@Getter
@AllArgsConstructor
@RedisHash(value = "refreshToken", timeToLive = 60 * 60 * 24 * 3)
public class RefreshToken {
@Id
private String UUID;
@Indexed
private String userId;
private String refreshToken;

public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
81 changes: 81 additions & 0 deletions src/main/java/dorun/project/routineapp/api/entity/User.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package dorun.project.routineapp.api.entity;

import dorun.project.routineapp.oauth.entity.ProviderType;
import dorun.project.routineapp.oauth.entity.RoleType;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "USER")
public class User extends BaseEntity {
@JsonIgnore
@Id
@Column(name = "USER_SEQ")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userSeq;

@Column(name = "USER_ID", length = 64, unique = true)
@NotNull
@Size(max = 64)
private String userId;

@Column(name = "USERNAME", length = 100)
@NotNull
@Size(max = 100)
private String username;

@JsonIgnore
@Column(name = "PASSWORD", length = 128)
@NotNull
@Size(max = 128)
private String password;

@Column(name = "EMAIL", length = 512, unique = true)
@NotNull
@Size(max = 512)
private String email;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

provider 마다 같은 email을 사용할 수 도 있지 않을까요?
따라서 (unique = true) 설정 하는게 맞는지 궁금합니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

이것은 사용자를 어떻게 관리할 것인지 논의가 조금 필요해보입니다. 하나의 이메일을 가진 사용자를 유일하게 설정하고 여러 provider에 대해 연동을 시켜줄지, 또는 provider 별로 이메일이 같은 사용자를 다른 사용자로 취급할지 등을 생각해봐야 할 것 같아요.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

저의 경우 여러 소셜로그인을 사용하는데 다른 provider 같은 이메일을 사용하곤하는데
만약 email이 unique 하다면 한 사용자가 여러 계정을 만드는 상황은 힘들것 같다는 생각입니다.

  • 의도적으로 여러 계정을 만들지 못한다면 unique = true가 적절하다고 생각합니다


@Column(name = "PROVIDER_TYPE", length = 20)
@Enumerated(EnumType.STRING)
@NotNull
private ProviderType providerType;

@Column(name = "ROLE_TYPE", length = 20)
@Enumerated(EnumType.STRING)
@NotNull
private RoleType roleType;

public User(
@NotNull @Size(max = 64) String userId,
@NotNull @Size(max = 100) String username,
@NotNull @Size(max = 512) String email,
@NotNull ProviderType providerType,
@NotNull RoleType roleType
) {
this.userId = userId;
this.username = username;
this.password = "NO_PASS";
this.email = email != null ? email : "NO_EMAIL";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

guest 로그인을 고려한 것으로 보이는데 만약 email 값이 null이라면 해당 유저가 guest라는 것을 boolean 변수 또는 enum를 통해서 설정하는것은 어떤가요?
(PROVIDER TYPE 으로 GUEST를 넣는 방식이라던지..?)

this.providerType = providerType;
this.roleType = roleType;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package dorun.project.routineapp.api.repository;

import dorun.project.routineapp.api.entity.RefreshToken;
import java.util.Optional;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface RefreshTokenRepository extends CrudRepository<RefreshToken, String> {
Optional<RefreshToken> findByUserId(String userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package dorun.project.routineapp.api.repository;

import dorun.project.routineapp.api.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
User findByUserId(String userId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Optional 을 이용해서 null에 대한 처리를 깔끔하게 할 수 있을 것이라고 생각합니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

수정하겠습니다.

}
47 changes: 47 additions & 0 deletions src/main/java/dorun/project/routineapp/common/APIResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package dorun.project.routineapp.common;

import java.util.HashMap;
import java.util.Map;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public class APIResponse<T> {

private final static int SUCCESS = 200;
private final static int NOT_FOUND = 400;
private final static int FAILED = 500;
private final static String SUCCESS_MESSAGE = "SUCCESS";
private final static String NOT_FOUND_MESSAGE = "NOT FOUND";
private final static String FAILED_MESSAGE = "서버에서 오류가 발생하였습니다.";
private final static String INVALID_ACCESS_TOKEN = "Access Token이 유효하지 않습니다.";
private final static String INVALID_REFRESH_TOKEN = "Refresh Token이 유효하지 않습니다.";
private final static String NOT_EXPIRED_TOKEN_YET = "Token이 아직 만료되지 않았습니다.";

private final APIResponseHeader header;
private final Map<String, T> body;

public static <T> APIResponse<T> success(String name, T body) {
Map<String, T> map = new HashMap<>();
map.put(name, body);

return new APIResponse<>(new APIResponseHeader(SUCCESS, SUCCESS_MESSAGE), map);
}

public static <T> APIResponse<T> fail() {
return new APIResponse<>(new APIResponseHeader(FAILED, FAILED_MESSAGE), null);
}

public static <T> APIResponse<T> invalidAccessToken() {
return new APIResponse<>(new APIResponseHeader(FAILED, INVALID_ACCESS_TOKEN), null);
}

public static <T> APIResponse<T> invalidRefreshToken() {
return new APIResponse<>(new APIResponseHeader(FAILED, INVALID_REFRESH_TOKEN), null);
}

public static <T> APIResponse<T> notExpiredTokenYet() {
return new APIResponse<>(new APIResponseHeader(FAILED, NOT_EXPIRED_TOKEN_YET), null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package dorun.project.routineapp.common;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@AllArgsConstructor
public class APIResponseHeader {
private int code;
private String message;
}
17 changes: 17 additions & 0 deletions src/main/java/dorun/project/routineapp/config/JwtConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package dorun.project.routineapp.config;

import dorun.project.routineapp.oauth.token.TokenProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JwtConfig {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

이 클래스가 있어야 할 이유를 잘 모르겠습니다..!

  • TokenProvider 에서 @component 를 추가하는 것과 다른점이 있을까요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

기본적인 목적은 yml 파일에서 바인딩되는 값이 TokenProvider 객체를 생성할 때 주입되도록 구현했는데, 그 값을 TokenProvider 클래스에 직접 바인딩하는 것을 피하기 위함이었습니다. 다시 말하면, TokenProvider가 멤버 변수인 key를 만들기 위한 jwt.secret 값을 가지고 있어야 하는지 의문이었어서 나누어 두고, 그것을 JwtConfig 클래스에서 직접 빈으로 등록하도록 구현했습니다.

@ComponentTokenProvider 클래스에 추가한다면 secret에 해당하는 값이 TokenProvider 클래스에 바인딩되어 있어야 할 거예요. 빈을 등록한다는 점에서는 똑같은 것이 맞습니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

제가 잘 몰라서 그러는데 yml 파일에서 가져오는 환경변수를 TokenProvider 클래스에서 직접 바인딩해서 사용하면 안되는 이유가 있을 까요?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

'안 된다' 라기보다 생성할 때 이외에 사용할 일이 없고, 프로그램이 실행되는 동안 계속 사용되는 객체가 그 값을 들고 있어야 하는 이유도 딱히 없다고 생각했습니다. 부수적으로, yml 파일의 값을 바인딩하는 코드를 config 패키지에 모아두는 것도 목적이었습니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

TokenProvider에서 결국 Secret와 같은 환경변수 값을 주입받아서 사용하는 형태이기 때문에 유지보수면에서 이를 분리해 환경변수 값에 변경이 일어났을 경우 해당 config 파일만을 수정하도록 해 유연하게 대응하도록 한 것이군요!

  • 그렇다면, authController에서 사용중인 UPDATE_TOKEN_STRATEGY 같은 값도 환경변수로 사용하고, 이를 해당 config 파일에 추가하여 TokenProvider가 사용하도록 하는 것도 좋을 것 같습니다.
  • Token 생성 / 유지 에 대해 필요한 값들이 약간 흩어져 있다고 생각합니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

이야기해주신 내용들이 구현을 꼼꼼하게 하지 못해서 그런 부분들입니다. 위에서 이야기한 목적에 부합하도록 수정해야 하는 부분이 맞습니다.

@Value("${jwt.secret}")
private String secret;

@Bean
public TokenProvider jwtProvider() {
return new TokenProvider(secret);
}
}
21 changes: 21 additions & 0 deletions src/main/java/dorun/project/routineapp/config/OpenApiConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package dorun.project.routineapp.config;

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI openAPI() {
Info info = new Info()
.title("소셜 로그인 템플릿 프로젝트 API Document")
.version("v0.0.1")
.description("소셜 로그인 템플릿 프로젝트의 API 명세서입니다.");
return new OpenAPI()
.components(new Components())
.info(info);
}
}
Loading