From e140d19d0ce1afb3dd020b4090221e5762bfbfcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=A9=ED=98=B8=EC=9C=A4?= Date: Sat, 9 Dec 2023 19:16:58 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/chwipoClova/common/dto/QToken.java | 57 ++++++ .../com/chwipoClova/user/entity/QUser.java | 49 +++++ .../config/ApiDocsOperationCustomizer.java | 63 +++++++ .../common/config/CommonConfig.java | 15 ++ .../common/config/JwtProvider.java | 141 +++++++++++++++ .../common/config/QueryDslConfig.java | 20 +++ .../common/config/SwaggerConfig.java | 46 +++++ .../com/chwipoClova/common/dto/Token.java | 65 +++++++ .../com/chwipoClova/common/dto/TokenDto.java | 18 ++ .../common/dto/UserDetailsImpl.java | 55 ++++++ .../common/exception/CommonException.java | 21 +++ .../common/exception/ExceptionAdvice.java | 23 +++ .../common/exception/ExceptionCode.java | 39 ++++ .../filter/CachedByteArrayInputStream.java | 33 ++++ .../common/filter/CustomRequestWrapper.java | 46 +++++ .../common/filter/JwtAuthFilter.java | 98 ++++++++++ .../common/filter/RequestFilter.java | 169 ++++++++++++++++++ .../common/repository/TokenRepository.java | 10 ++ .../common/response/CommonMsgResponse.java | 12 ++ .../common/response/CommonResponse.java | 16 ++ .../common/response/MessageCode.java | 40 +++++ .../common/response/ResponseAdvice.java | 34 ++++ .../common/response/ResponseUtil.java | 7 + .../service/JwtAuthenticationEntryPoint.java | 33 ++++ .../service/UserDetailsServiceImpl.java | 30 ++++ .../chwipoClova/common/utils/DateUtils.java | 17 ++ .../com/chwipoClova/common/utils/JwtUtil.java | 124 +++++++++++++ .../java/com/chwipoClova/tmp/entity/Tmp.java | 10 +- .../user/controller/UserController.java | 45 +++++ .../com/chwipoClova/user/dto/KakaoToken.java | 28 +++ .../chwipoClova/user/dto/KakaoUserInfo.java | 48 +++++ .../com/chwipoClova/user/entity/User.java | 65 +++++++ .../chwipoClova/user/entity/UsersEditor.java | 19 ++ .../chwipoClova/user/enums/UserLoginType.java | 29 +++ .../user/repository/UserRepository.java | 10 ++ .../user/request/UserLoginReq.java | 4 + .../user/response/UserLoginRes.java | 37 ++++ .../user/response/UserSnsUrlRes.java | 13 ++ .../chwipoClova/user/service/UserService.java | 169 ++++++++++++++++++ 39 files changed, 1753 insertions(+), 5 deletions(-) create mode 100644 src/main/generated/com/chwipoClova/common/dto/QToken.java create mode 100644 src/main/generated/com/chwipoClova/user/entity/QUser.java create mode 100644 src/main/java/com/chwipoClova/common/config/ApiDocsOperationCustomizer.java create mode 100644 src/main/java/com/chwipoClova/common/config/CommonConfig.java create mode 100644 src/main/java/com/chwipoClova/common/config/JwtProvider.java create mode 100644 src/main/java/com/chwipoClova/common/config/QueryDslConfig.java create mode 100644 src/main/java/com/chwipoClova/common/config/SwaggerConfig.java create mode 100644 src/main/java/com/chwipoClova/common/dto/Token.java create mode 100644 src/main/java/com/chwipoClova/common/dto/TokenDto.java create mode 100644 src/main/java/com/chwipoClova/common/dto/UserDetailsImpl.java create mode 100644 src/main/java/com/chwipoClova/common/exception/CommonException.java create mode 100644 src/main/java/com/chwipoClova/common/exception/ExceptionAdvice.java create mode 100644 src/main/java/com/chwipoClova/common/exception/ExceptionCode.java create mode 100644 src/main/java/com/chwipoClova/common/filter/CachedByteArrayInputStream.java create mode 100644 src/main/java/com/chwipoClova/common/filter/CustomRequestWrapper.java create mode 100644 src/main/java/com/chwipoClova/common/filter/JwtAuthFilter.java create mode 100644 src/main/java/com/chwipoClova/common/filter/RequestFilter.java create mode 100644 src/main/java/com/chwipoClova/common/repository/TokenRepository.java create mode 100644 src/main/java/com/chwipoClova/common/response/CommonMsgResponse.java create mode 100644 src/main/java/com/chwipoClova/common/response/CommonResponse.java create mode 100644 src/main/java/com/chwipoClova/common/response/MessageCode.java create mode 100644 src/main/java/com/chwipoClova/common/response/ResponseAdvice.java create mode 100644 src/main/java/com/chwipoClova/common/response/ResponseUtil.java create mode 100644 src/main/java/com/chwipoClova/common/service/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/com/chwipoClova/common/service/UserDetailsServiceImpl.java create mode 100644 src/main/java/com/chwipoClova/common/utils/DateUtils.java create mode 100644 src/main/java/com/chwipoClova/common/utils/JwtUtil.java create mode 100644 src/main/java/com/chwipoClova/user/controller/UserController.java create mode 100644 src/main/java/com/chwipoClova/user/dto/KakaoToken.java create mode 100644 src/main/java/com/chwipoClova/user/dto/KakaoUserInfo.java create mode 100644 src/main/java/com/chwipoClova/user/entity/User.java create mode 100644 src/main/java/com/chwipoClova/user/entity/UsersEditor.java create mode 100644 src/main/java/com/chwipoClova/user/enums/UserLoginType.java create mode 100644 src/main/java/com/chwipoClova/user/repository/UserRepository.java create mode 100644 src/main/java/com/chwipoClova/user/request/UserLoginReq.java create mode 100644 src/main/java/com/chwipoClova/user/response/UserLoginRes.java create mode 100644 src/main/java/com/chwipoClova/user/response/UserSnsUrlRes.java create mode 100644 src/main/java/com/chwipoClova/user/service/UserService.java diff --git a/src/main/generated/com/chwipoClova/common/dto/QToken.java b/src/main/generated/com/chwipoClova/common/dto/QToken.java new file mode 100644 index 0000000..e96fb75 --- /dev/null +++ b/src/main/generated/com/chwipoClova/common/dto/QToken.java @@ -0,0 +1,57 @@ +package com.chwipoClova.common.dto; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QToken is a Querydsl query type for Token + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QToken extends EntityPathBase { + + private static final long serialVersionUID = 1987504615L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QToken token = new QToken("token"); + + public final DateTimePath modifyDate = createDateTime("modifyDate", java.util.Date.class); + + public final StringPath refreshToken = createString("refreshToken"); + + public final DateTimePath regDate = createDateTime("regDate", java.util.Date.class); + + public final NumberPath tokenId = createNumber("tokenId", Long.class); + + public final com.chwipoClova.user.entity.QUser user; + + public QToken(String variable) { + this(Token.class, forVariable(variable), INITS); + } + + public QToken(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QToken(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QToken(PathMetadata metadata, PathInits inits) { + this(Token.class, metadata, inits); + } + + public QToken(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.user = inits.isInitialized("user") ? new com.chwipoClova.user.entity.QUser(forProperty("user")) : null; + } + +} + diff --git a/src/main/generated/com/chwipoClova/user/entity/QUser.java b/src/main/generated/com/chwipoClova/user/entity/QUser.java new file mode 100644 index 0000000..fceaf42 --- /dev/null +++ b/src/main/generated/com/chwipoClova/user/entity/QUser.java @@ -0,0 +1,49 @@ +package com.chwipoClova.user.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QUser is a Querydsl query type for User + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QUser extends EntityPathBase { + + private static final long serialVersionUID = -1980796269L; + + public static final QUser user = new QUser("user"); + + public final StringPath email = createString("email"); + + public final DateTimePath modifyDate = createDateTime("modifyDate", java.util.Date.class); + + public final StringPath name = createString("name"); + + public final DateTimePath regDate = createDateTime("regDate", java.util.Date.class); + + public final NumberPath snsId = createNumber("snsId", Long.class); + + public final NumberPath snsType = createNumber("snsType", Integer.class); + + public final NumberPath userId = createNumber("userId", Long.class); + + public QUser(String variable) { + super(User.class, forVariable(variable)); + } + + public QUser(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QUser(PathMetadata metadata) { + super(User.class, metadata); + } + +} + diff --git a/src/main/java/com/chwipoClova/common/config/ApiDocsOperationCustomizer.java b/src/main/java/com/chwipoClova/common/config/ApiDocsOperationCustomizer.java new file mode 100644 index 0000000..8722585 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/config/ApiDocsOperationCustomizer.java @@ -0,0 +1,63 @@ +package com.chwipoClova.common.config; + +import com.chwipoClova.common.exception.ExceptionCode; +import com.chwipoClova.common.response.CommonResponse; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.responses.ApiResponses; +import org.apache.commons.lang3.StringUtils; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; +import org.springframework.web.method.HandlerMethod; + +import java.util.Map; + +@Configuration +public class ApiDocsOperationCustomizer implements OperationCustomizer { + + @Override + public Operation customize(Operation operation, + HandlerMethod handlerMethod) { + ApiResponses apiResponses = operation.getResponses(); + apiResponses.forEach((code, apiResponse) -> { + Content content = operation.getResponses().get(code).getContent(); + if (content != null) { + content.keySet().forEach(mediaTypeKey -> { + final MediaType mediaType = content.get(mediaTypeKey); + mediaType.schema(customizeSchema(code, mediaType.getSchema())); + }); + } + }); + return operation; + } + + private Schema customizeSchema(String code, Schema objSchema) { + ModelConverters mc= ModelConverters.getInstance(); + Map read = mc.read(CommonResponse.class); + Schema wrapperSchema = read.get("CommonResponse"); + + String responseCode; + String responseMessage; + + if (StringUtils.equals(code, String.valueOf(HttpStatus.OK.value()))) { + responseCode = String.valueOf(HttpStatus.OK.value()); + responseMessage = HttpStatus.OK.getReasonPhrase(); + } else { + ExceptionCode exceptionCode = ExceptionCode.resolve(code); + responseCode = exceptionCode.getCode(); + responseMessage = exceptionCode.getMessage(); + } + wrapperSchema.addProperties("data", objSchema); + wrapperSchema.addProperty("code", new StringSchema()._default(responseCode)); + wrapperSchema.addProperty("message", new StringSchema()._default(responseMessage)); + return wrapperSchema; + } + +} + + diff --git a/src/main/java/com/chwipoClova/common/config/CommonConfig.java b/src/main/java/com/chwipoClova/common/config/CommonConfig.java new file mode 100644 index 0000000..ccc8c1d --- /dev/null +++ b/src/main/java/com/chwipoClova/common/config/CommonConfig.java @@ -0,0 +1,15 @@ +package com.chwipoClova.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class CommonConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + +} diff --git a/src/main/java/com/chwipoClova/common/config/JwtProvider.java b/src/main/java/com/chwipoClova/common/config/JwtProvider.java new file mode 100644 index 0000000..ecfede4 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/config/JwtProvider.java @@ -0,0 +1,141 @@ +package com.chwipoClova.common.config; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; + +import java.security.Key; +import java.util.Date; + +@Slf4j +public class JwtProvider { + + private final long ACCESS_TOKEN_EXPIRE_TIME; // 30분 + private final long REFRESH_TOKEN_EXPIRE_TIME; // 7일 + + private final Key key; + + public JwtProvider(@Value("${jwt.secret}") String secretKey , + @Value("${jwt.access-token-expire-time}") long accessTime, + @Value("${jwt.refresh-token-expire-time}") long refreshTime + ) { + this.ACCESS_TOKEN_EXPIRE_TIME = accessTime; + this.REFRESH_TOKEN_EXPIRE_TIME = refreshTime; + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + + protected String createToken(String id, long tokenValid) { + // ex) sub : abc@abc.com + Claims claims = Jwts.claims().setSubject(id); + +/* // ex) auth : ROLE_USER,ROLE_ADMIN + claims.put(AUTHORITIES_KEY, + auth.stream() + .map(Authority::getAuthorityName) + .collect(Collectors.joining(",")) + );*/ + + // 현재시간 + Date now = new Date(); + + return Jwts.builder() + .setClaims(claims) // 토큰 발행 유저 정보 + .setIssuedAt(now) // 토큰 발행 시간 + .setExpiration(new Date(now.getTime() + tokenValid)) // 토큰 만료시간 + .signWith(key, SignatureAlgorithm.HS512) // 키와 알고리즘 설정 + .compact(); + } + + /** + * + * @param id + * @return 엑세스 토큰 생성 + */ + public String createAccessToken(String id) { + return this.createToken(id, ACCESS_TOKEN_EXPIRE_TIME); + } + + /** + * + * @param id + * @return 리프레시 토큰 생성 + */ + public String createRefreshToken(String id) { + return this.createToken(id, REFRESH_TOKEN_EXPIRE_TIME); + } + + /** + * + * @param token + * @return 토큰 값을 파싱하여 클레임에 담긴 id 값을 가져온다. + */ + public String getMemberIdByToken(String token) { + // 토큰의 claim 의 sub 키에 이메일 값이 들어있다. + return this.parseClaims(token).getSubject(); + } + +/* public TokenDTO createTokenDTO(String accessToken,String refreshToken) { + return TokenDTO.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .grantType(BEARER_TYPE) + .build(); + }*/ +/* + public Authentication getAuthentication(String accessToken) throws BizException{ + + // 토큰 복호화 + Claims claims = parseClaims(accessToken); + + if (claims.get(AUTHORITIES_KEY) == null || !StringUtils.hasText(claims.get(AUTHORITIES_KEY).toString())) { + throw new BizException(AuthorityExceptionType.NOT_FOUND_AUTHORITY); // 유저에게 아무런 권한이 없습니다. + } + + log.debug("claims.getAuth = {}",claims.get(AUTHORITIES_KEY)); + log.debug("claims.getEmail = {}",claims.getSubject()); + + // 클레임에서 권한 정보 가져오기 + Collection authorities = + Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + authorities.stream().forEach(o->{ + log.debug("getAuthentication -> authorities = {}",o.getAuthority()); + }); + + // UserDetails 객체를 만들어서 Authentication 리턴 + UserDetails principal = new User(claims.getSubject(), "", authorities); + + return new CustomEmailPasswordAuthToken(principal, "", authorities); + }*/ + + public int validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return 1; + } catch (ExpiredJwtException e) { + log.info("만료된 JWT 토큰입니다."); + return 2; + } catch (Exception e) { + log.info("잘못된 토큰입니다."); + return -1; + } + } + + + private Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody(); + } catch (ExpiredJwtException e) { // 만료된 토큰이 더라도 일단 파싱을 함 + return e.getClaims(); + } + } +} diff --git a/src/main/java/com/chwipoClova/common/config/QueryDslConfig.java b/src/main/java/com/chwipoClova/common/config/QueryDslConfig.java new file mode 100644 index 0000000..0c84ac3 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/config/QueryDslConfig.java @@ -0,0 +1,20 @@ +package com.chwipoClova.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/chwipoClova/common/config/SwaggerConfig.java b/src/main/java/com/chwipoClova/common/config/SwaggerConfig.java new file mode 100644 index 0000000..dd5c81b --- /dev/null +++ b/src/main/java/com/chwipoClova/common/config/SwaggerConfig.java @@ -0,0 +1,46 @@ +package com.chwipoClova.common.config; + +import com.chwipoClova.common.utils.JwtUtil; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + String key = JwtUtil.ACCESS_TOKEN; + String refreshKey = JwtUtil.REFRESH_TOKEN; + + return new OpenAPI() + .addSecurityItem(new SecurityRequirement() + .addList(key) + .addList(refreshKey) + ) + .info(apiInfo()) + .components(new Components() + .addSecuritySchemes(key, new SecurityScheme() + .name(key) + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .bearerFormat("JWT")) + .addSecuritySchemes(refreshKey, new SecurityScheme() + .name(refreshKey) + .type(SecurityScheme.Type.APIKEY) + .bearerFormat("JWT") + .in(SecurityScheme.In.HEADER)) + + ); + } + + private Info apiInfo() { + return new Info() + .title("Springdoc") + .description("Springdoc을 사용한 Swagger UI") + .version("1.0.0"); + } +} diff --git a/src/main/java/com/chwipoClova/common/dto/Token.java b/src/main/java/com/chwipoClova/common/dto/Token.java new file mode 100644 index 0000000..c282a4e --- /dev/null +++ b/src/main/java/com/chwipoClova/common/dto/Token.java @@ -0,0 +1,65 @@ +package com.chwipoClova.common.dto; + +import com.chwipoClova.user.entity.User; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; + +import java.util.Date; + +@Data +@Entity(name = "Token") +@Table(name = "Token") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties() +@DynamicInsert +@Builder +@Getter +@Setter +@ToString +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "Token VO") +public class Token { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long tokenId; + @NotBlank + private String refreshToken; + + private Date regDate; + + private Date modifyDate; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "userId") + private User user; + + public Token(String token, User user) { + this.refreshToken = token; + this.user = user; + } + + public Token updateToken(String token) { + this.refreshToken = token; + return this; + } + + + // @PrePersist 메서드 정의 (최초 등록시 호출) + @PrePersist + public void prePersist() { + this.regDate = new Date(); // 현재 날짜와 시간으로 등록일 설정 + } + + // @PreUpdate 메서드 정의 (업데이트 시 호출) + @PreUpdate + public void preUpdate() { + this.modifyDate = new Date(); // 현재 날짜와 시간으로 수정일 업데이트 + } +} diff --git a/src/main/java/com/chwipoClova/common/dto/TokenDto.java b/src/main/java/com/chwipoClova/common/dto/TokenDto.java new file mode 100644 index 0000000..4b9c50d --- /dev/null +++ b/src/main/java/com/chwipoClova/common/dto/TokenDto.java @@ -0,0 +1,18 @@ +package com.chwipoClova.common.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TokenDto { + + private String accessToken; + private String refreshToken; + + public TokenDto(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + +} diff --git a/src/main/java/com/chwipoClova/common/dto/UserDetailsImpl.java b/src/main/java/com/chwipoClova/common/dto/UserDetailsImpl.java new file mode 100644 index 0000000..9399e81 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/dto/UserDetailsImpl.java @@ -0,0 +1,55 @@ +package com.chwipoClova.common.dto; + +import com.chwipoClova.user.entity.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; + +public class UserDetailsImpl implements UserDetails { + + private User user; + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + @Override + public Collection getAuthorities() { + return null; + } + + @Override + public String getPassword() { + return null; + } + + @Override + public String getUsername() { + return null; + } + + @Override + public boolean isAccountNonExpired() { + return false; + } + + @Override + public boolean isAccountNonLocked() { + return false; + } + + @Override + public boolean isCredentialsNonExpired() { + return false; + } + + @Override + public boolean isEnabled() { + return false; + } +} diff --git a/src/main/java/com/chwipoClova/common/exception/CommonException.java b/src/main/java/com/chwipoClova/common/exception/CommonException.java new file mode 100644 index 0000000..9e75592 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/exception/CommonException.java @@ -0,0 +1,21 @@ +package com.chwipoClova.common.exception; + +import lombok.Getter; + +@Getter +public class CommonException extends RuntimeException { + + private final String errorCode; + + public CommonException(String message, String errorCode) { + super(message); + this.errorCode = errorCode; + } + + public CommonException(String message, Throwable cause, String errorCode) { + super(message, cause); + this.errorCode = errorCode; + } + +} + diff --git a/src/main/java/com/chwipoClova/common/exception/ExceptionAdvice.java b/src/main/java/com/chwipoClova/common/exception/ExceptionAdvice.java new file mode 100644 index 0000000..24ac850 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/exception/ExceptionAdvice.java @@ -0,0 +1,23 @@ +package com.chwipoClova.common.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class ExceptionAdvice { + + // @ExceptionHandler(value = {NoUserExistException.class, WrongPasswordException.class}) + @ExceptionHandler(CommonException.class) + public CommonException handleCommonException(CommonException e) { + log.error("CommonException({}) - {}", e.getClass().getSimpleName(), e.getMessage()); + return e; + } + + @ExceptionHandler(Exception.class) + public CommonException handleException(Exception e) { + log.error("Exception({}) - {}", e.getClass().getSimpleName(), e.getMessage()); + return new CommonException(ExceptionCode.SERVER_ERROR.getMessage(), ExceptionCode.SERVER_ERROR.getCode()); + } +} diff --git a/src/main/java/com/chwipoClova/common/exception/ExceptionCode.java b/src/main/java/com/chwipoClova/common/exception/ExceptionCode.java new file mode 100644 index 0000000..3735b38 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/exception/ExceptionCode.java @@ -0,0 +1,39 @@ +package com.chwipoClova.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; +import org.springframework.http.HttpStatus; + +@Getter +@ToString +@AllArgsConstructor +public enum ExceptionCode { + + BAD_REQUEST(String.valueOf(HttpStatus.BAD_REQUEST.value()), "잘못된 요청입니다."), + + NOT_FOUND(String.valueOf(HttpStatus.NOT_FOUND.value()), "요청한 페이지를 찾을 수 없습니다."), + + SERVER_ERROR(String.valueOf(HttpStatus.INTERNAL_SERVER_ERROR.value()), "내부 서버 오류입니다."), + + // Custom Exception + SECURITY("600", "로그인이 필요합니다"); + + private static final ExceptionCode[] VALUES; + + static { + VALUES = values(); + } + + private final String code; + private final String message; + + public static ExceptionCode resolve(String statusCode) { + for (ExceptionCode status : VALUES) { + if (status.code.equals(statusCode)) { + return status; + } + } + return null; + } +} diff --git a/src/main/java/com/chwipoClova/common/filter/CachedByteArrayInputStream.java b/src/main/java/com/chwipoClova/common/filter/CachedByteArrayInputStream.java new file mode 100644 index 0000000..e428008 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/filter/CachedByteArrayInputStream.java @@ -0,0 +1,33 @@ +package com.chwipoClova.common.filter; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; + +import java.io.ByteArrayInputStream; + +public class CachedByteArrayInputStream extends ServletInputStream { + private ByteArrayInputStream in; + + public CachedByteArrayInputStream(byte[] body) { + this.in = new ByteArrayInputStream(body); + } + + @Override + public boolean isFinished() { + return false; + } + + @Override + public boolean isReady() { + return false; + } + + @Override + public void setReadListener(ReadListener readListener) { + } + + @Override + public int read() { + return in.read(); + } +} \ No newline at end of file diff --git a/src/main/java/com/chwipoClova/common/filter/CustomRequestWrapper.java b/src/main/java/com/chwipoClova/common/filter/CustomRequestWrapper.java new file mode 100644 index 0000000..069ffe2 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/filter/CustomRequestWrapper.java @@ -0,0 +1,46 @@ +package com.chwipoClova.common.filter; + +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import lombok.extern.slf4j.Slf4j; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.IOException; + +@Slf4j +public class CustomRequestWrapper extends HttpServletRequestWrapper { + + private byte[] body; + + public CustomRequestWrapper(HttpServletRequest httpServletRequest) { + super(httpServletRequest); + try { + DataInputStream dis = new DataInputStream(httpServletRequest.getInputStream()); + ByteArrayOutputStream os = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + for (int len = dis.read(buffer); len != -1; len = dis.read(buffer)) { + os.write(buffer, 0, len); + } + os.flush(); + this.body = os.toByteArray(); + } catch (IOException ioe) { + log.error("IOException {}", ioe); + } + } + + public byte[] getBody() { + return body; + } + + public void setBody(byte[] body) { + this.body = body; + } + + @Override + public ServletInputStream getInputStream() { + return new CachedByteArrayInputStream(this.body); + } + +} \ No newline at end of file diff --git a/src/main/java/com/chwipoClova/common/filter/JwtAuthFilter.java b/src/main/java/com/chwipoClova/common/filter/JwtAuthFilter.java new file mode 100644 index 0000000..c9521e1 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/filter/JwtAuthFilter.java @@ -0,0 +1,98 @@ +package com.chwipoClova.common.filter; + +import com.chwipoClova.common.response.CommonResponse; +import com.chwipoClova.common.utils.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + @Override + // HTTP 요청이 오면 WAS(tomcat)가 HttpServletRequest, HttpServletResponse 객체를 만들어 줍니다. + // 만든 인자 값을 받아옵니다. + // 요청이 들어오면 diFilterInternal 이 딱 한번 실행된다. + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // WebSecurityConfig 에서 보았던 UsernamePasswordAuthenticationFilter 보다 먼저 동작을 하게 됩니다. + + // Access / Refresh 헤더에서 토큰을 가져옴. + String accessToken = jwtUtil.getHeaderToken(request, "Access"); + String refreshToken = jwtUtil.getHeaderToken(request, "Refresh"); + + if(accessToken != null) { + // 어세스 토큰값이 유효하다면 setAuthentication를 통해 + // security context에 인증 정보저장 + if(jwtUtil.tokenValidation(accessToken)){ + jwtUtil.setHeaderAccessToken(response, accessToken); + jwtUtil.setHeaderRefreshToken(response, refreshToken); + setAuthentication(jwtUtil.getIdFromToken(accessToken)); + } + // 어세스 토큰이 만료된 상황 && 리프레시 토큰 또한 존재하는 상황 + else if (refreshToken != null) { + // 리프레시 토큰 검증 && 리프레시 토큰 DB에서 토큰 존재유무 확인 + boolean isRefreshToken = jwtUtil.refreshTokenValidation(refreshToken); + // 리프레시 토큰이 유효하고 리프레시 토큰이 DB와 비교했을때 똑같다면 + if (isRefreshToken) { + // 리프레시 토큰으로 아이디 정보 가져오기 + String loginId = jwtUtil.getIdFromToken(refreshToken); + // 새로운 어세스 토큰 발급 + String newAccessToken = jwtUtil.createToken(loginId, "Access"); + // 헤더에 어세스 토큰 추가 + jwtUtil.setHeaderAccessToken(response, newAccessToken); + jwtUtil.setHeaderRefreshToken(response, refreshToken); + // Security context에 인증 정보 넣기 + setAuthentication(jwtUtil.getIdFromToken(newAccessToken)); + } + // 리프레시 토큰이 만료 || 리프레시 토큰이 DB와 비교했을때 똑같지 않다면 + else { + jwtExceptionHandler(response, "RefreshToken Expired", HttpStatus.BAD_REQUEST); + return; + } + } + } + filterChain.doFilter(request,response); + } + + // SecurityContext 에 Authentication 객체를 저장합니다. + public void setAuthentication(String subject) { + try { + if (StringUtils.isNotBlank(subject)) { + Long id = Long.parseLong(subject); + Authentication authentication = jwtUtil.createAuthentication(id); + // security가 만들어주는 securityContextHolder 그 안에 authentication을 넣어줍니다. + // security가 securitycontextholder에서 인증 객체를 확인하는데 + // jwtAuthfilter에서 authentication을 넣어주면 UsernamePasswordAuthenticationFilter 내부에서 인증이 된 것을 확인하고 추가적인 작업을 진행하지 않습니다. + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception e) { + log.error("token id 변환에 실패했습니다. {}", e); + } + } + + // Jwt 예외처리 + public void jwtExceptionHandler(HttpServletResponse response, String msg, HttpStatus status) { + response.setStatus(status.value()); + response.setContentType("application/json"); + try { + String json = new ObjectMapper().writeValueAsString(new CommonResponse("RefreshToken Expired", null,"878")); + response.getWriter().write(json); + } catch (Exception e) { + log.error(e.getMessage()); + } + } +} diff --git a/src/main/java/com/chwipoClova/common/filter/RequestFilter.java b/src/main/java/com/chwipoClova/common/filter/RequestFilter.java new file mode 100644 index 0000000..8493895 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/filter/RequestFilter.java @@ -0,0 +1,169 @@ +package com.chwipoClova.common.filter; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; +import org.springframework.web.util.WebUtils; + +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@Component +public class RequestFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + + + ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper((HttpServletRequest) request); + ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper((HttpServletResponse) response); + CustomRequestWrapper customRequestWrapper = new CustomRequestWrapper(requestWrapper); + + chain.doFilter(customRequestWrapper, responseWrapper); + + long start = System.currentTimeMillis(); + long end = System.currentTimeMillis(); + + if (customRequestWrapper.getRequestURI().indexOf("/api-docs/") == -1 && customRequestWrapper.getRequestURI().indexOf("/swagger-ui/") == -1) { + log.info("\n" + + "[REQUEST] {} - {} {} - {}\n" + + "Headers : {}\n" + + "Request : {}\n" + + "Response : {}\n", + ((HttpServletRequest) customRequestWrapper).getMethod(), + ((HttpServletRequest) customRequestWrapper).getRequestURI(), + responseWrapper.getStatus(), + (end - start) / 1000.0, + getHeaders(customRequestWrapper), + buildAccessLog(customRequestWrapper), + getResponseBody(responseWrapper)); + } else { + log.info("[REQUEST] {} - {} {} - {}", ((HttpServletRequest) customRequestWrapper).getMethod(), ((HttpServletRequest) customRequestWrapper).getRequestURI(), responseWrapper.getStatus(), (end - start) / 1000.0); + getResponseBody(responseWrapper); + } + } + + private Map getHeaders(HttpServletRequest request) { + Map headerMap = new HashMap<>(); + + Enumeration headerArray = request.getHeaderNames(); + while (headerArray.hasMoreElements()) { + String headerName = (String) headerArray.nextElement(); + headerMap.put(headerName, request.getHeader(headerName)); + } + return headerMap; + } + + private String getResponseBody(final HttpServletResponse response) throws IOException { + String payload = null; + ContentCachingResponseWrapper wrapper = + WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class); + if (wrapper != null) { + byte[] buf = wrapper.getContentAsByteArray(); + if (buf.length > 0) { + payload = new String(buf, 0, buf.length, "UTF-8"); + wrapper.copyBodyToResponse(); + } + } + return null == payload ? " - " : payload; + } + + private String buildAccessLog(CustomRequestWrapper customRequestWrapper) { + + try { + String requestURL = getRequestURL(customRequestWrapper); + String remoteAddr = getRemoteAddr(customRequestWrapper); + String method = getMethod(customRequestWrapper); + String queryString = getQueryString(customRequestWrapper); + String requestBody = getRequestBody(customRequestWrapper); + + StringBuilder sb = new StringBuilder(); + sb.append("{"); + if (requestURL != null) { + sb + .append("\"").append("requestURL").append("\"") + .append(":") + .append("\"").append(requestURL).append("\""); + } + if (remoteAddr != null) { + sb + .append(",") + .append("\"").append("remoteAddr").append("\"") + .append(":") + .append("\"").append(remoteAddr).append("\""); + } + if (method != null) { + sb + .append(",") + .append("\"").append("method").append("\"") + .append(":") + .append("\"").append(method).append("\""); + } + if (queryString != null) { + sb + .append(",") + .append("\"").append("queryString").append("\"") + .append(":") + .append("\"").append(queryString).append("\""); + } + if (requestBody != null && requestBody.length() > 0) { + sb + .append(",") + .append("\"").append("body").append("\"") + .append(":") + .append("\"").append(requestBody).append("\""); + } + sb.append("}"); + return sb.toString(); + } catch (Exception e) { + log.error("buildAccessLog Exception {}", e); + } + return null; + } + + private String getRequestBody(CustomRequestWrapper customRequestWrapper) { + String content = null; + String method = customRequestWrapper.getMethod().toLowerCase(); + + // POST, PUT + application/json + if (method.startsWith("p")) { + if (customRequestWrapper.getContentType().toLowerCase().indexOf("json") > 0) { + try { + content = new String(customRequestWrapper.getBody(), customRequestWrapper.getCharacterEncoding()); + } catch (UnsupportedEncodingException e) { + log.error(e.getMessage()); + } + } + } + return content; + } + + private String getQueryString(CustomRequestWrapper customRequestWrapper) throws UnsupportedEncodingException { + String queryString = null; + if (customRequestWrapper.getQueryString() != null) { + queryString = URLDecoder.decode(customRequestWrapper.getQueryString(), "UTF-8"); + } + return queryString; + } + + private String getMethod(CustomRequestWrapper customRequestWrapper) { + return customRequestWrapper.getMethod(); + } + + private String getRemoteAddr(CustomRequestWrapper customRequestWrapper) { + return customRequestWrapper.getHeader("X-Forwarded-For") == null ? customRequestWrapper.getRemoteAddr() : customRequestWrapper.getHeader("X-Forwarded-For"); + } + + private String getRequestURL(CustomRequestWrapper customRequestWrapper) { + return customRequestWrapper.getRequestURL().toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/chwipoClova/common/repository/TokenRepository.java b/src/main/java/com/chwipoClova/common/repository/TokenRepository.java new file mode 100644 index 0000000..43f0011 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/repository/TokenRepository.java @@ -0,0 +1,10 @@ +package com.chwipoClova.common.repository; + +import com.chwipoClova.common.dto.Token; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TokenRepository extends JpaRepository { + Optional findByUserUserId(Long id); +} diff --git a/src/main/java/com/chwipoClova/common/response/CommonMsgResponse.java b/src/main/java/com/chwipoClova/common/response/CommonMsgResponse.java new file mode 100644 index 0000000..ff60df6 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/response/CommonMsgResponse.java @@ -0,0 +1,12 @@ +package com.chwipoClova.common.response; + +import lombok.Data; + +@Data +public class CommonMsgResponse { + private String message; + + public CommonMsgResponse(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/chwipoClova/common/response/CommonResponse.java b/src/main/java/com/chwipoClova/common/response/CommonResponse.java new file mode 100644 index 0000000..d57d6f5 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/response/CommonResponse.java @@ -0,0 +1,16 @@ +package com.chwipoClova.common.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class CommonResponse { + + private final String code; + + private final T data; + + private final String message; + +} diff --git a/src/main/java/com/chwipoClova/common/response/MessageCode.java b/src/main/java/com/chwipoClova/common/response/MessageCode.java new file mode 100644 index 0000000..90dabdf --- /dev/null +++ b/src/main/java/com/chwipoClova/common/response/MessageCode.java @@ -0,0 +1,40 @@ +package com.chwipoClova.common.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +@AllArgsConstructor +public enum MessageCode { + + SUCCESS_SAVE("SS", "정상적으로 저장되었습니다."), + + SUCCESS_DELETE("SD", "정상적으로 삭제되었습니다."), + + FAIL_SAVE("FS", "저장에 실패하였습니다."), + + FAIL_DELETE("FD", "삭제에 실패하였습니다."), + + SUCCESS("S", "정상적으로 처리되었습니다.") + ; + + private static final MessageCode[] VALUES; + + static { + VALUES = values(); + } + + private final String code; + private final String message; + + public static MessageCode resolve(String statusCode) { + for (MessageCode status : VALUES) { + if (status.code.equals(statusCode)) { + return status; + } + } + return null; + } +} diff --git a/src/main/java/com/chwipoClova/common/response/ResponseAdvice.java b/src/main/java/com/chwipoClova/common/response/ResponseAdvice.java new file mode 100644 index 0000000..e126c08 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/response/ResponseAdvice.java @@ -0,0 +1,34 @@ +package com.chwipoClova.common.response; + +import com.chwipoClova.common.exception.CommonException; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +@RestControllerAdvice +public class ResponseAdvice implements ResponseBodyAdvice { + + @Override + public boolean supports(MethodParameter returnType, Class> converterType) { + return true; + } + + @Override + public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { + if (selectedContentType.isCompatibleWith(MediaType.APPLICATION_JSON) && !StringUtils.startsWith(request.getURI().getPath(), "/api-docs/")) { + if (body instanceof CommonException) { + return ResponseUtil.response(((CommonException) body).getErrorCode(), null, ((CommonException) body).getMessage()); + } else { + return ResponseUtil.response(String.valueOf(HttpStatus.OK.value()), body, HttpStatus.OK.getReasonPhrase()); + } + } else { + return body; + } + } +} diff --git a/src/main/java/com/chwipoClova/common/response/ResponseUtil.java b/src/main/java/com/chwipoClova/common/response/ResponseUtil.java new file mode 100644 index 0000000..4b4a86a --- /dev/null +++ b/src/main/java/com/chwipoClova/common/response/ResponseUtil.java @@ -0,0 +1,7 @@ +package com.chwipoClova.common.response; + +public class ResponseUtil { + public static CommonResponse response(String code, T response, String message) { + return new CommonResponse<>(code, response, message); + } +} diff --git a/src/main/java/com/chwipoClova/common/service/JwtAuthenticationEntryPoint.java b/src/main/java/com/chwipoClova/common/service/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..69d45bf --- /dev/null +++ b/src/main/java/com/chwipoClova/common/service/JwtAuthenticationEntryPoint.java @@ -0,0 +1,33 @@ +package com.chwipoClova.common.service; + +import com.chwipoClova.common.exception.ExceptionCode; +import com.chwipoClova.common.response.CommonResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +@Slf4j +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { + setResponse(response, ExceptionCode.SECURITY.getMessage()); + } + + private void setResponse(HttpServletResponse response, String message) throws IOException { + log.error("[exceptionHandle] AuthenticationEntryPoint = {}", message); + ObjectMapper objectMapper = new ObjectMapper(); + response.setStatus(HttpStatus.OK.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("utf-8"); + response.getWriter().write(objectMapper.writeValueAsString(new CommonResponse(ExceptionCode.SECURITY.getMessage(),null, ExceptionCode.SECURITY.getCode()) )); + } +} \ No newline at end of file diff --git a/src/main/java/com/chwipoClova/common/service/UserDetailsServiceImpl.java b/src/main/java/com/chwipoClova/common/service/UserDetailsServiceImpl.java new file mode 100644 index 0000000..cf1cc67 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/service/UserDetailsServiceImpl.java @@ -0,0 +1,30 @@ +package com.chwipoClova.common.service; + +import com.chwipoClova.common.dto.UserDetailsImpl; +import com.chwipoClova.user.entity.User; +import com.chwipoClova.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class UserDetailsServiceImpl { + + private final UserRepository userRepository; + + public UserDetails loadUserByUserId(Long id) throws UsernameNotFoundException { + User user = userRepository.findById(id).orElseThrow(() -> { + log.error("토큰 id정보가 올바르지 않습니다."); + return null; + } + ); + UserDetailsImpl userDetails = new UserDetailsImpl(); + userDetails.setUser(user); + + return userDetails; + } +} diff --git a/src/main/java/com/chwipoClova/common/utils/DateUtils.java b/src/main/java/com/chwipoClova/common/utils/DateUtils.java new file mode 100644 index 0000000..78da74f --- /dev/null +++ b/src/main/java/com/chwipoClova/common/utils/DateUtils.java @@ -0,0 +1,17 @@ +package com.chwipoClova.common.utils; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; + +public class DateUtils { + + public static String getStringDateFormat(String asisFormat, String tobeFormat, String value) { + return LocalDate.parse(value, DateTimeFormatter.ofPattern(asisFormat)).format(DateTimeFormatter.ofPattern(tobeFormat)); + } + + public static String getStringTimeFormat(String asisFormat, String tobeFormat, String value) { + return LocalTime.parse(value, DateTimeFormatter.ofPattern(asisFormat)).format(DateTimeFormatter.ofPattern(tobeFormat)); + } + +} diff --git a/src/main/java/com/chwipoClova/common/utils/JwtUtil.java b/src/main/java/com/chwipoClova/common/utils/JwtUtil.java new file mode 100644 index 0000000..450bfe6 --- /dev/null +++ b/src/main/java/com/chwipoClova/common/utils/JwtUtil.java @@ -0,0 +1,124 @@ +package com.chwipoClova.common.utils; + +import com.chwipoClova.common.dto.Token; +import com.chwipoClova.common.dto.TokenDto; +import com.chwipoClova.common.repository.TokenRepository; +import com.chwipoClova.common.service.UserDetailsServiceImpl; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Base64; +import java.util.Date; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtUtil { + + private final UserDetailsServiceImpl userDetailsService; + private final TokenRepository tokenRepository; + + private static final long ACCESS_TIME = 2 * 60 * 60 * 1000L; + private static final long REFRESH_TIME = 14 * 24 * 60 * 60 * 1000L; + public static final String ACCESS_TOKEN = "AccessToken"; + public static final String REFRESH_TOKEN = "RefreshToken"; + + @Value("${jwt.secretKey}") + private String secretKey; + + private Key key; + private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; + + // bean으로 등록 되면서 딱 한번 실행이 됩니다. + @PostConstruct + public void init() { + byte[] bytes = Base64.getDecoder().decode(secretKey); + key = Keys.hmacShaKeyFor(bytes); + } + + // header 토큰을 가져오는 기능 + public String getHeaderToken(HttpServletRequest request, String type) { + return type.equals("Access") ? request.getHeader(ACCESS_TOKEN) :request.getHeader(REFRESH_TOKEN); + } + + // 토큰 생성 + public TokenDto createAllToken(String userId) { + return new TokenDto(createToken(userId, "Access"), createToken(userId, "Refresh")); + } + + public String createToken(String id, String type) { + + Date date = new Date(); + + long time = type.equals("Access") ? ACCESS_TIME : REFRESH_TIME; + + return Jwts.builder() + .setSubject(id) + .setExpiration(new Date(date.getTime() + time)) + .setIssuedAt(date) + .signWith(key, signatureAlgorithm) + .compact(); + + } + + // 토큰 검증 + public Boolean tokenValidation(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (Exception ex) { + log.error(ex.getMessage()); + return false; + } + } + + // refreshToken 토큰 검증 + // db에 저장되어 있는 token과 비교 + // db에 저장한다는 것이 jwt token을 사용한다는 강점을 상쇄시킨다. + // db 보다는 redis를 사용하는 것이 더욱 좋다. (in-memory db기 때문에 조회속도가 빠르고 주기적으로 삭제하는 기능이 기본적으로 존재합니다.) + public Boolean refreshTokenValidation(String token) { + + // 1차 토큰 검증 + if(!tokenValidation(token)) return false; + + // DB에 저장한 토큰 비교 + Optional refreshToken = tokenRepository.findByUserUserId(Long.parseLong(getIdFromToken(token))); + + return refreshToken.isPresent() && token.equals(refreshToken.get().getRefreshToken()); + } + + // 인증 객체 생성 + public Authentication createAuthentication(Long id) { + UserDetails userDetails = userDetailsService.loadUserByUserId(id); + // spring security 내에서 가지고 있는 객체입니다. (UsernamePasswordAuthenticationToken) + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } + + // 토큰에서 id 가져오는 기능 + public String getIdFromToken(String token) { + return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().getSubject(); + } + + // 어세스 토큰 헤더 설정 + public void setHeaderAccessToken(HttpServletResponse response, String accessToken) { + response.setHeader("Access_Token", accessToken); + } + + // 리프레시 토큰 헤더 설정 + public void setHeaderRefreshToken(HttpServletResponse response, String refreshToken) { + response.setHeader("Refresh_Token", refreshToken); + } +} diff --git a/src/main/java/com/chwipoClova/tmp/entity/Tmp.java b/src/main/java/com/chwipoClova/tmp/entity/Tmp.java index bb5b34b..fad45fd 100644 --- a/src/main/java/com/chwipoClova/tmp/entity/Tmp.java +++ b/src/main/java/com/chwipoClova/tmp/entity/Tmp.java @@ -19,22 +19,22 @@ public class Tmp { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Schema(description = "fld01") + @Schema(description = "fld01", example = "1", name = "fld01") private Long fld01; @Column(name = "fld02") - @Schema(description = "fld02") + @Schema(description = "fld02", example = "1", name = "fld02") private String fld02; @Column(name = "fld03") - @Schema(description = "fld03") + @Schema(description = "fld03", example = "1", name = "fld03") private String fld03; @Column(name = "fld04") - @Schema(description = "fld04") + @Schema(description = "fld04", example = "1", name = "fld04") private String fld04; @Column(name = "fld05") - @Schema(description = "fld05") + @Schema(description = "fld05", example = "1", name = "fld05") private String fld05; } diff --git a/src/main/java/com/chwipoClova/user/controller/UserController.java b/src/main/java/com/chwipoClova/user/controller/UserController.java new file mode 100644 index 0000000..8d9f4ae --- /dev/null +++ b/src/main/java/com/chwipoClova/user/controller/UserController.java @@ -0,0 +1,45 @@ +package com.chwipoClova.user.controller; + +import com.chwipoClova.user.response.UserLoginRes; +import com.chwipoClova.user.response.UserSnsUrlRes; +import com.chwipoClova.user.service.UserService; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@Tag(name = "User", description = "유저 API") +@RequestMapping("user") +public class UserController { + + private final UserService userService; + + @Operation(summary = "카카오 로그인 URL", description = "카카오 로그인 URL") + @GetMapping("/getKakaoUrl") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "OK") + } + ) + public UserSnsUrlRes getKakaoUrl() throws Exception { + return userService.getKakaoUrl(); + } + @Hidden + @GetMapping("/kakaoCallback") + public UserLoginRes kakaoCallback(@RequestParam(name = "code") String code, HttpServletResponse response) throws Exception { + return userService.kakaoLogin(code, response); + } + +} diff --git a/src/main/java/com/chwipoClova/user/dto/KakaoToken.java b/src/main/java/com/chwipoClova/user/dto/KakaoToken.java new file mode 100644 index 0000000..9960345 --- /dev/null +++ b/src/main/java/com/chwipoClova/user/dto/KakaoToken.java @@ -0,0 +1,28 @@ +package com.chwipoClova.user.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class KakaoToken { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("expires_in") + private Integer expiresIn; + + @JsonProperty("refresh_token_expires_in") + private Integer refreshTokenExpiresIn; + + @JsonProperty("scope") + private String scope; +} diff --git a/src/main/java/com/chwipoClova/user/dto/KakaoUserInfo.java b/src/main/java/com/chwipoClova/user/dto/KakaoUserInfo.java new file mode 100644 index 0000000..63c600b --- /dev/null +++ b/src/main/java/com/chwipoClova/user/dto/KakaoUserInfo.java @@ -0,0 +1,48 @@ +package com.chwipoClova.user.dto; + +import com.chwipoClova.user.enums.UserLoginType; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +import java.util.Date; + +@Getter +@JsonIgnoreProperties(ignoreUnknown = true) +public class KakaoUserInfo { + + @JsonProperty("id") + private Long id; + + @JsonProperty("connected_at") + private Date connectedAt; + + @JsonProperty("kakao_account") + private KakaoAccount kakaoAccount; + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + static class KakaoAccount { + private KakaoProfile profile; + + private String email; + } + + @Getter + @JsonIgnoreProperties(ignoreUnknown = true) + static class KakaoProfile { + private String nickname; + } + + public String getEmail() { + return kakaoAccount.email; + } + + public String getNickname() { + return kakaoAccount.profile.nickname; + } + + public UserLoginType getOAuthProvider() { + return UserLoginType.KAKAO; + } +} diff --git a/src/main/java/com/chwipoClova/user/entity/User.java b/src/main/java/com/chwipoClova/user/entity/User.java new file mode 100644 index 0000000..813fe0f --- /dev/null +++ b/src/main/java/com/chwipoClova/user/entity/User.java @@ -0,0 +1,65 @@ +package com.chwipoClova.user.entity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; + +import java.util.Date; + +@Entity(name = "User") +@Table(name = "User") +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties() +@DynamicInsert +@Builder +@Getter +@ToString +@AllArgsConstructor +@NoArgsConstructor +@Schema(description = "유저 정보 VO") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "userId") + @Schema(description = "아이디") + private Long userId; + + @Column(name = "name") + @Schema(description = "이름") + private String name; + + @Column(name = "email") + @Schema(description = "이메일") + private String email; + + @Column(name = "regDate") + @Schema(description = "가입일") + private Date regDate; + + @Column(name = "modifyDate") + @Schema(description = "수정일") + private Date modifyDate; + + @Column(name = "snsType") + @Schema(description = "소셜 로그인 플랫폼 (1 : 카카오)") + private Integer snsType; + + @Column(name = "snsId") + @Schema(description = "소셜회원 ID") + private Long snsId; + + public UsersEditor.UsersEditorBuilder toEditor() { + return UsersEditor.builder() + .name(name) + .modifyDate(modifyDate); + } + + public void edit(UsersEditor usersEditor) { + name = usersEditor.getName(); + modifyDate = usersEditor.getModifyDate(); + } +} diff --git a/src/main/java/com/chwipoClova/user/entity/UsersEditor.java b/src/main/java/com/chwipoClova/user/entity/UsersEditor.java new file mode 100644 index 0000000..0f8402d --- /dev/null +++ b/src/main/java/com/chwipoClova/user/entity/UsersEditor.java @@ -0,0 +1,19 @@ +package com.chwipoClova.user.entity; + +import lombok.Builder; +import lombok.Getter; + +import java.util.Date; + +@Getter +public class UsersEditor { + + private String name; + private Date modifyDate; + + @Builder + public UsersEditor(String name, Date modifyDate) { + this.name = name; + this.modifyDate = modifyDate; + } +} diff --git a/src/main/java/com/chwipoClova/user/enums/UserLoginType.java b/src/main/java/com/chwipoClova/user/enums/UserLoginType.java new file mode 100644 index 0000000..eaa8cec --- /dev/null +++ b/src/main/java/com/chwipoClova/user/enums/UserLoginType.java @@ -0,0 +1,29 @@ +package com.chwipoClova.user.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public enum UserLoginType { + KAKAO(1, "카카오") + ; + + private final Integer code; + private final String name; + + private static final UserLoginType[] VALUES; + + static { + VALUES = values(); + } + + public static String getLoginTypeName(Integer code) { + for (UserLoginType status : VALUES) { + if (status.code == code) { + return status.getName(); + } + } + return ""; + } +} diff --git a/src/main/java/com/chwipoClova/user/repository/UserRepository.java b/src/main/java/com/chwipoClova/user/repository/UserRepository.java new file mode 100644 index 0000000..c39b2ec --- /dev/null +++ b/src/main/java/com/chwipoClova/user/repository/UserRepository.java @@ -0,0 +1,10 @@ +package com.chwipoClova.user.repository; + +import com.chwipoClova.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findBySnsTypeAndSnsId(Integer SnsType, Long snsId); +} diff --git a/src/main/java/com/chwipoClova/user/request/UserLoginReq.java b/src/main/java/com/chwipoClova/user/request/UserLoginReq.java new file mode 100644 index 0000000..5c55228 --- /dev/null +++ b/src/main/java/com/chwipoClova/user/request/UserLoginReq.java @@ -0,0 +1,4 @@ +package com.chwipoClova.user.request; + +public class UserLoginReq { +} diff --git a/src/main/java/com/chwipoClova/user/response/UserLoginRes.java b/src/main/java/com/chwipoClova/user/response/UserLoginRes.java new file mode 100644 index 0000000..4887e37 --- /dev/null +++ b/src/main/java/com/chwipoClova/user/response/UserLoginRes.java @@ -0,0 +1,37 @@ +package com.chwipoClova.user.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Column; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Data; + +import java.util.Date; + +@Data +@Builder +public class UserLoginRes { + + @Schema(description = "아이디", example = "1", name = "userId") + private Long userId; + + @Schema(description = "이름", example = "홍길동", name = "name") + private String name; + + @Schema(description = "이메일", example = "test@naver.com", name = "email") + private String email; + + @Schema(description = "가입일", example = "2023-12-09T10:13:17.838+00:00", name = "regDate") + private Date regDate; + + @Schema(description = "수정일", example = "2023-12-09T10:13:17.838+00:00", name = "modifyDate") + private Date modifyDate; + + @Schema(description = "소셜 로그인 플랫폼 (1 : 카카오)", example = "1", name = "snsType") + private Integer snsType; + + @Schema(description = "소셜회원 ID", example = "11314", name = "snsId") + private Long snsId; +} diff --git a/src/main/java/com/chwipoClova/user/response/UserSnsUrlRes.java b/src/main/java/com/chwipoClova/user/response/UserSnsUrlRes.java new file mode 100644 index 0000000..fabc79a --- /dev/null +++ b/src/main/java/com/chwipoClova/user/response/UserSnsUrlRes.java @@ -0,0 +1,13 @@ +package com.chwipoClova.user.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class UserSnsUrlRes { + + @Schema(description = "SNS 로그인 URL", example = "https://sns.com", name = "url") + private String url; +} diff --git a/src/main/java/com/chwipoClova/user/service/UserService.java b/src/main/java/com/chwipoClova/user/service/UserService.java new file mode 100644 index 0000000..32b694d --- /dev/null +++ b/src/main/java/com/chwipoClova/user/service/UserService.java @@ -0,0 +1,169 @@ +package com.chwipoClova.user.service; + +import com.chwipoClova.common.dto.Token; +import com.chwipoClova.common.dto.TokenDto; +import com.chwipoClova.common.repository.TokenRepository; +import com.chwipoClova.common.utils.JwtUtil; +import com.chwipoClova.user.dto.KakaoToken; +import com.chwipoClova.user.dto.KakaoUserInfo; +import com.chwipoClova.user.entity.User; +import com.chwipoClova.user.repository.UserRepository; +import com.chwipoClova.user.response.UserLoginRes; +import com.chwipoClova.user.response.UserSnsUrlRes; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.util.Date; +import java.util.Optional; + + +@RequiredArgsConstructor +@Service +@Slf4j +public class UserService { + + private final UserRepository userRepository; + + private final TokenRepository tokenRepository; + + private final RestTemplate restTemplate; + + private final JwtUtil jwtUtil; + + @Value("${kakao.url.auth}") + private String kakaoAuthUrl; + + @Value("${kakao.url.token}") + private String tokenUrl; + + @Value("${kakao.url.api}") + private String apiUrl; + + @Value("${kakao.client_id}") + private String clientId; + + @Value("${kakao.client_secret}") + private String clientSecret; + + @Value("${kakao.grant_type}") + private String grantType; + + @Value("${kakao.redirect_uri}") + private String redirectUri; + + + public UserSnsUrlRes getKakaoUrl() { + String kakaoUrl = kakaoAuthUrl + "?response_type=code" + "&client_id=" + clientId + + "&redirect_uri=" + redirectUri; + UserSnsUrlRes userSnsUrlRes = UserSnsUrlRes.builder() + .url(kakaoUrl) + .build(); + return userSnsUrlRes; + } + + public UserLoginRes kakaoLogin(String code, HttpServletResponse response) { + KakaoToken kakaoToken = requestAccessToken(code); + KakaoUserInfo kakaoUserInfo = requestOauthInfo(kakaoToken); + + long snsId = kakaoUserInfo.getId(); + String email = kakaoUserInfo.getEmail(); + String nickname = kakaoUserInfo.getNickname(); + Integer snsType = kakaoUserInfo.getOAuthProvider().getCode(); + + Optional userInfo = userRepository.findBySnsTypeAndSnsId(snsType, snsId); + + User userInfoRst; + // 유저 정보가 있다면 업데이트 없으면 등록 + if (userInfo.isPresent()) { + userInfoRst = userInfo.get(); + } else { + log.info("신규유저 등록 {}", nickname); + User user = User.builder() + .snsId(snsId) + .email(email) + .name(nickname) + .snsType(kakaoUserInfo.getOAuthProvider().getCode()) + .regDate(new Date()) + .build(); + userInfoRst = userRepository.save(user); + } + + Long userId = userInfoRst.getUserId(); + + TokenDto tokenDto = jwtUtil.createAllToken(String.valueOf(userId)); + + // Refresh토큰 있는지 확인 + Optional refreshToken = tokenRepository.findByUserUserId(userInfoRst.getUserId()); + + // 있다면 새토큰 발급후 업데이트 + // 없다면 새로 만들고 디비 저장 + if(refreshToken.isPresent()) { + tokenRepository.save(refreshToken.get().updateToken(tokenDto.getRefreshToken())); + }else { + Token newToken = new Token(tokenDto.getRefreshToken(), User.builder().userId(userInfoRst.getUserId()).build()); + tokenRepository.save(newToken); + } + + // response 헤더에 Access Token / Refresh Token 넣음 + setHeader(response, tokenDto); + + return UserLoginRes.builder() + .snsId(userInfoRst.getSnsId()) + .userId(userId) + .email(userInfoRst.getEmail()) + .name(userInfoRst.getName()) + .snsType(userInfoRst.getSnsType()) + .regDate(userInfoRst.getRegDate()) + .modifyDate(userInfoRst.getModifyDate()) + .build(); + } + + public KakaoToken requestAccessToken(String code) { + + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>();; + body.add("grant_type", grantType); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + body.add("redirect_uri", redirectUri); + body.add("code", code); + + HttpEntity> request = new HttpEntity<>(body, httpHeaders); + + KakaoToken response = restTemplate.postForObject(tokenUrl, request, KakaoToken.class); + + // TODO 토큰 정보를 가져오지 못하면 예외발생 처리 추가 + assert response != null; + return response; + } + + public KakaoUserInfo requestOauthInfo(KakaoToken kakaoToken) { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + httpHeaders.set("Authorization", "Bearer " + kakaoToken.getAccessToken()); + + MultiValueMap body = new LinkedMultiValueMap<>();; + HttpEntity> request = new HttpEntity<>(body, httpHeaders); + KakaoUserInfo response = restTemplate.postForObject(apiUrl, request, KakaoUserInfo.class); + + // TODO 유저 정보를 가져오지 못하면 예외발생 처리 추가 + assert response != null; + return response; + } + + private void setHeader(HttpServletResponse response, TokenDto tokenDto) { + response.addHeader(JwtUtil.ACCESS_TOKEN, tokenDto.getAccessToken()); + response.addHeader(JwtUtil.REFRESH_TOKEN, tokenDto.getRefreshToken()); + } +}