diff --git a/.github/workflows/cd-pipeline.yml b/.github/workflows/cd-pipeline.yml index 2378d74..4fd3ef1 100644 --- a/.github/workflows/cd-pipeline.yml +++ b/.github/workflows/cd-pipeline.yml @@ -1,10 +1,9 @@ name: Java CD with Gradle on: - push: + pull_request: branches: - - main - - feature/KOBG-4/initial-setting + - develop permissions: contents: read @@ -53,7 +52,6 @@ jobs: username: ${{ secrets.USERNAME }} key: ${{ secrets.KEY }} script: | - cd /home/ubuntu/app - export IMAGE_TAG=${{ github.sha }} sudo docker compose up -d --pull always + sudo docker compose up -d --pull always sudo docker ps sudo docker image prune -f diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 17405f8..3fb3794 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -29,7 +29,7 @@ jobs: run: | cd ./src/main/resources touch ./application.yml - echo "${{ secrets.PROPERTIES }}" > ./application.yml + echo "${{ secrets.PROPERTIES_TEST }}" > ./application.yml shell: bash - name: πŸš€ Redis μ‹€ν–‰ diff --git a/build.gradle b/build.gradle index 7d835eb..f680a92 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,15 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // google api client + implementation 'com.google.api-client:google-api-client:2.4.0' + implementation 'com.google.http-client:google-http-client-jackson2:1.41.5' + + // jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.2' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.2' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.2' + // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8' @@ -47,6 +56,7 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testImplementation 'org.mockito:mockito-core:5.18.0' testImplementation 'org.mockito:mockito-junit-jupiter:5.18.0' + testImplementation 'com.h2database:h2' } compileJava.options.encoding = 'UTF-8' diff --git a/src/main/java/com/edu/kobridge/KobridgeApplication.java b/src/main/java/com/edu/kobridge/KobridgeApplication.java index cddcf21..3846808 100644 --- a/src/main/java/com/edu/kobridge/KobridgeApplication.java +++ b/src/main/java/com/edu/kobridge/KobridgeApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class KobridgeApplication { diff --git a/src/main/java/com/edu/kobridge/global/common/filter/JwtAuthorizationFilter.java b/src/main/java/com/edu/kobridge/global/common/filter/JwtAuthorizationFilter.java new file mode 100644 index 0000000..477044e --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/common/filter/JwtAuthorizationFilter.java @@ -0,0 +1,98 @@ +package com.edu.kobridge.global.common.filter; + +import java.io.IOException; + +import com.edu.kobridge.global.error.ErrorCode; +import com.edu.kobridge.global.error.GlobalErrorCode; +import com.edu.kobridge.global.error.exception.AppException; +import com.edu.kobridge.global.error.exception.FilterException; +import com.edu.kobridge.global.util.JwtUtil; +import com.edu.kobridge.global.util.ResponseUtil; +import com.edu.kobridge.user.domain.entity.User; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.FilterConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RequiredArgsConstructor +@Slf4j +public class JwtAuthorizationFilter implements Filter { + private final JwtUtil jwtUtil; + + // JWT 검사 μ œμ™Έν•  경둜 μ„€μ • + final String LOGIN_PATH = "/api/user/google-login"; + final String TOKEN_PATH = "/api/user/token"; + + @Override + public void init(FilterConfig filterConfig) throws ServletException { + log.info("Filter initialized."); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + HttpServletRequest req = (HttpServletRequest)request; + HttpServletResponse res = (HttpServletResponse)response; + + // μš”μ²­ URIλ₯Ό κ°€μ Έμ˜΄ + String requestURI = req.getRequestURI(); + + // swagger 인증 필터링 없이 처리 + if (requestURI.startsWith("/swagger-ui/") || + requestURI.startsWith("/webjars/") || + requestURI.startsWith("/v3/api-docs")) { + chain.doFilter(request, response); + return; + } + + // 둜그인 및 토큰 μž¬λ°œκΈ‰ μš”μ²­μ€ JWT 인증 필터링 없이 처리 + if (requestURI.equals(LOGIN_PATH) && req.getMethod().equals("GET")) { + chain.doFilter(request, response); + return; + } else if (requestURI.equals(TOKEN_PATH) && req.getMethod().equals("GET")) { + chain.doFilter(request, response); + return; + } else if (requestURI.contains("test/no-auth")) { + chain.doFilter(request, response); + return; + } + + try { + // Authorization ν—€λ”μ—μ„œ JWT 토큰을 κ°€μ Έμ˜΄ + String header = req.getHeader("Authorization"); + if (header == null) { + throw new FilterException(GlobalErrorCode.ACCESS_TOKEN_REQUIRED); + } + + // 토큰 μœ νš¨μ„± 검사 ν›„ μ‚¬μš©μž 정보 μΆ”μΆœ + User user = jwtUtil.validateToken(true, header); + req.setAttribute("user", user); + + // ν•„ν„° 체인 λ‹€μŒ ν•„ν„°λ‘œ 이동 + chain.doFilter(request, response); + } catch (FilterException e) { // 토큰이 μ—†λŠ” 경우 + ResponseUtil.setResponse(res, e.getErrorCode().getHttpStatus().value(), e.getMessage()); + } catch (AppException e) { // 토큰이 μœ νš¨ν•˜μ§€ μ•Šμ€ 경우 + ResponseUtil.setResponse(res, e.getErrorCode().getHttpStatus().value(), e.getMessage()); + } catch (JwtException e) { // JWT 토큰 μ˜ˆμ™Έ 처리 + ErrorCode code = + e instanceof ExpiredJwtException ? GlobalErrorCode.EXPIRED_JWT : GlobalErrorCode.INVALID_TOKEN; + + ResponseUtil.setResponse(res, code.getHttpStatus().value(), code.getMessage()); + } + } + + @Override + public void destroy() { + log.info("Filter destroyed."); + } +} diff --git a/src/main/java/com/edu/kobridge/global/config/RedisConfig.java b/src/main/java/com/edu/kobridge/global/config/RedisConfig.java new file mode 100644 index 0000000..a700134 --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/config/RedisConfig.java @@ -0,0 +1,43 @@ +package com.edu.kobridge.global.config; + +import org.springframework.beans.factory.annotation.Value; +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.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import lombok.Getter; + +@Configuration +@Getter +@EnableRedisRepositories +public class RedisConfig { + @Value("${spring.data.redis.host}") + private String host; + @Value("${spring.data.redis.port}") + private int port; + + // λ ˆλ””μŠ€ μ—°κ²° + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration(); + redisConfiguration.setHostName(host); + redisConfiguration.setPort(port); + + return new LettuceConnectionFactory(redisConfiguration); + } + + // λ¬Έμžμ—΄ ν‚€ 직렬화 + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} diff --git a/src/main/java/com/edu/kobridge/global/config/WebMvcConfig.java b/src/main/java/com/edu/kobridge/global/config/WebMvcConfig.java new file mode 100644 index 0000000..39e3bc0 --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/config/WebMvcConfig.java @@ -0,0 +1,29 @@ +package com.edu.kobridge.global.config; + +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.edu.kobridge.global.common.filter.JwtAuthorizationFilter; +import com.edu.kobridge.global.util.JwtUtil; + +import jakarta.servlet.Filter; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + private final JwtUtil jwtUtil; + + // jwt ν•„ν„° 등둝 + @Bean + public FilterRegistrationBean filterBean() { + FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>( + new JwtAuthorizationFilter(jwtUtil)); + filterRegistrationBean.setOrder(1); + filterRegistrationBean.addUrlPatterns("/*"); + + return filterRegistrationBean; + } +} diff --git a/src/main/java/com/edu/kobridge/global/enums/JwtVo.java b/src/main/java/com/edu/kobridge/global/enums/JwtVo.java new file mode 100644 index 0000000..350b970 --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/enums/JwtVo.java @@ -0,0 +1,11 @@ +package com.edu.kobridge.global.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class JwtVo { + private final String accessToken; + private final String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/com/edu/kobridge/global/enums/LangType.java b/src/main/java/com/edu/kobridge/global/enums/LangType.java new file mode 100644 index 0000000..8d34f10 --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/enums/LangType.java @@ -0,0 +1,29 @@ +package com.edu.kobridge.global.enums; + +import java.util.Arrays; + +import lombok.Getter; + +@Getter +public enum LangType { + ENG("ENG", "μ˜μ–΄"), + VET("VET", "λ² νŠΈλ‚¨μ–΄"), + CHN("CHN", "쀑ꡭ어"), + JPN("JPN", "일본어"), + NONE("NONE", "none"); + + private final String code; + private final String name; + + LangType(String code, String name) { + this.code = code; + this.name = name; + } + + public static UserRoleType of(String code) { + return Arrays.stream(UserRoleType.values()) + .filter(r -> r.getCode().equals(code)) + .findAny() + .orElse(null); + } +} diff --git a/src/main/java/com/edu/kobridge/global/enums/SchoolType.java b/src/main/java/com/edu/kobridge/global/enums/SchoolType.java new file mode 100644 index 0000000..4bb703f --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/enums/SchoolType.java @@ -0,0 +1,29 @@ +package com.edu.kobridge.global.enums; + +import java.util.Arrays; + +import lombok.Getter; + +@Getter +public enum SchoolType { + ELEMENTARY("elementary", "μ΄ˆλ“±ν•™κ΅", 6), + MIDDLE("middle-school", "쀑학ꡐ", 3), + HIGH("high-school", "고등학ꡐ", 3); + + private final String code; + private final String name; + private final int maxGrade; + + SchoolType(String code, String name, int maxGrade) { + this.code = code; + this.name = name; + this.maxGrade = maxGrade; + } + + public static SchoolType of(String code) { + return Arrays.stream(SchoolType.values()) + .filter(r -> r.getCode().equals(code)) + .findAny() + .orElse(null); + } +} diff --git a/src/main/java/com/edu/kobridge/global/enums/UserRoleType.java b/src/main/java/com/edu/kobridge/global/enums/UserRoleType.java new file mode 100644 index 0000000..3557475 --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/enums/UserRoleType.java @@ -0,0 +1,27 @@ +package com.edu.kobridge.global.enums; + +import java.util.Arrays; + +import lombok.Getter; + +@Getter +public enum UserRoleType { + USER("ROLE_USER", "일반 μ‚¬μš©μž κΆŒν•œ"), + ADMIN("ROLE_ADMIN", "κ΄€λ¦¬μž κΆŒν•œ"), + GUEST("GUEST", "게슀트 κΆŒν•œ"); + + private final String code; + private final String name; + + UserRoleType(String code, String name) { + this.code = code; + this.name = name; + } + + public static UserRoleType of(String code) { + return Arrays.stream(UserRoleType.values()) + .filter(r -> r.getCode().equals(code)) + .findAny() + .orElse(GUEST); + } +} diff --git a/src/main/java/com/edu/kobridge/global/enums/VoiceType.java b/src/main/java/com/edu/kobridge/global/enums/VoiceType.java new file mode 100644 index 0000000..fed6ac7 --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/enums/VoiceType.java @@ -0,0 +1,27 @@ +package com.edu.kobridge.global.enums; + +import java.util.Arrays; + +import lombok.Getter; + +@Getter +public enum VoiceType { + ONE("GIRL_ONE"), + TWO("GIRL_TWO"), + THREE("BOY_ONE"), + FOUR("BOY_TWO"); + + // μΆ”ν›„ voice ν™•μ • 되면 name ν•„λ“œ μΆ”κ°€ + private final String code; + + VoiceType(String code) { + this.code = code; + } + + public static VoiceType of(String code) { + return Arrays.stream(VoiceType.values()) + .filter(r -> r.getCode().equals(code)) + .findAny() + .orElse(null); + } +} diff --git a/src/main/java/com/edu/kobridge/global/error/GlobalErrorCode.java b/src/main/java/com/edu/kobridge/global/error/GlobalErrorCode.java index 1cf72eb..8db06b5 100644 --- a/src/main/java/com/edu/kobridge/global/error/GlobalErrorCode.java +++ b/src/main/java/com/edu/kobridge/global/error/GlobalErrorCode.java @@ -6,6 +6,13 @@ @Getter public enum GlobalErrorCode implements ErrorCode { + AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€."), + AUTHORIZATION_FAILED(HttpStatus.UNAUTHORIZED, "인가에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€."), + ACCESS_TOKEN_REQUIRED(HttpStatus.UNAUTHORIZED, "Access Token이 ν•„μš”ν•©λ‹ˆλ‹€."), + REFRESH_TOKEN_REQUIRED(HttpStatus.UNAUTHORIZED, "Refresh Token이 ν•„μš”ν•©λ‹ˆλ‹€."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "Token이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), + EXPIRED_JWT(HttpStatus.FORBIDDEN, "Token이 λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "ν•΄λ‹Ήν•˜λŠ” μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."), MISSING_HEADER(HttpStatus.BAD_REQUEST, "ν•„μˆ˜ μš”μ²­ 헀더가 λˆ„λ½λ˜μ—ˆμŠ΅λ‹ˆλ‹€."), METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "μš”μ²­ κ²½λ‘œκ°€ μ§€μ›λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "λ‚΄λΆ€ μ„œλ²„ 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."); diff --git a/src/main/java/com/edu/kobridge/global/error/exception/FilterException.java b/src/main/java/com/edu/kobridge/global/error/exception/FilterException.java new file mode 100644 index 0000000..7168ab6 --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/error/exception/FilterException.java @@ -0,0 +1,18 @@ +package com.edu.kobridge.global.error.exception; + +import com.edu.kobridge.global.error.ErrorCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class FilterException extends RuntimeException { + private ErrorCode errorCode; + private String message; + + public FilterException(ErrorCode errorCode) { + this.errorCode = errorCode; + this.message = errorCode.getMessage(); + } +} diff --git a/src/main/java/com/edu/kobridge/global/error/handler/GlobalExceptionHandler.java b/src/main/java/com/edu/kobridge/global/error/handler/GlobalExceptionHandler.java index 86c9372..d4aea85 100644 --- a/src/main/java/com/edu/kobridge/global/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/edu/kobridge/global/error/handler/GlobalExceptionHandler.java @@ -15,6 +15,7 @@ import com.edu.kobridge.global.error.ErrorCode; import com.edu.kobridge.global.error.GlobalErrorCode; import com.edu.kobridge.global.error.exception.AppException; +import com.edu.kobridge.global.error.exception.FilterException; import lombok.extern.slf4j.Slf4j; @@ -35,6 +36,18 @@ public ResponseEntity exceptionHandler(AppException e) { return ResponseEntity.status(code).body(ResponseDto.of(code, message)); } + // FilterException + @ExceptionHandler(FilterException.class) + public ResponseEntity exceptionHandler(FilterException e) { + int code = e.getErrorCode().getHttpStatus().value(); + String message = e.getMessage(); + + if (code == 500) + e.printStackTrace(); + + return ResponseEntity.status(code).body(ResponseDto.of(code, message)); + } + // HttpMessageNotReadableException @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { diff --git a/src/main/java/com/edu/kobridge/global/util/GoogleOAuthUtil.java b/src/main/java/com/edu/kobridge/global/util/GoogleOAuthUtil.java new file mode 100644 index 0000000..17c7029 --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/util/GoogleOAuthUtil.java @@ -0,0 +1,49 @@ +package com.edu.kobridge.global.util; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.Collections; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.edu.kobridge.global.error.exception.AppException; +import com.edu.kobridge.user.domain.entity.User; +import com.edu.kobridge.user.error.UserErrorCode; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.gson.GsonFactory; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class GoogleOAuthUtil { + + @Value("${spring.security.oauth2.client.registration.google.client-id}") + private String CLIENT_ID; + + public User authenticate(String idToken) + throws GeneralSecurityException, IOException { + // idToken μœ νš¨μ„± 확인을 μœ„ν•œ Google 인증 μ„€μ • + HttpTransport transport = GoogleNetHttpTransport.newTrustedTransport(); + GsonFactory gsonFactory = GsonFactory.getDefaultInstance(); + GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(transport, gsonFactory) + .setAudience(Collections.singletonList(CLIENT_ID)) + .build(); + + // μ „λ‹¬λœ idToken을 κ²€μ¦ν•˜μ—¬ GoogleIdToken 객체 생성 + GoogleIdToken googleIdToken = verifier.verify(idToken); + if (googleIdToken == null) { + throw new AppException(UserErrorCode.INVALID_ID_TOKEN); + } + + // κ²€μ¦λœ ID ν† ν°μ˜ νŽ˜μ΄λ‘œλ“œμ—μ„œ μ‚¬μš©μž 정보 μΆ”μΆœ + GoogleIdToken.Payload payload = googleIdToken.getPayload(); + String email = payload.getEmail(); + + return User.of(email); + } +} diff --git a/src/main/java/com/edu/kobridge/global/util/JwtUtil.java b/src/main/java/com/edu/kobridge/global/util/JwtUtil.java new file mode 100644 index 0000000..7d94507 --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/util/JwtUtil.java @@ -0,0 +1,125 @@ +package com.edu.kobridge.global.util; + +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.edu.kobridge.global.enums.JwtVo; +import com.edu.kobridge.global.error.GlobalErrorCode; +import com.edu.kobridge.global.error.exception.AppException; +import com.edu.kobridge.user.domain.entity.User; +import com.edu.kobridge.user.domain.repository.UserRepository; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class JwtUtil { + @Value("${jwt.issuer}") + private String ISSUER; + + @Value("${jwt.secret}") + private String JWT_SECRET_KEY; + + @Value("${jwt.access-token-expiration}") + private int ACCESS_TOKEN_EXPIRATION; + + @Getter + @Value("${jwt.refresh-token-expiration}") + private int REFRESH_TOKEN_EXPIRATION; + + private final String PAYLOAD_KEY_ID = "id"; + + private final UserRepository userRepository; + private final RedisUtil redisUtil; + + public JwtVo generateTokens(User user) { + final String PAYLOAD_KEY_EMAIL = "email"; + + // payload에 μ‚¬μš©μž 식별 κ°’ μΆ”κ°€ + Map payloads = new LinkedHashMap<>(); + payloads.put(PAYLOAD_KEY_ID, user.getId()); + payloads.put(PAYLOAD_KEY_EMAIL, user.getEmail()); + + // Token μœ νš¨κΈ°κ°„ μ„€μ • + Date now = new Date(); + Date accessExp = new Date(now.getTime() + ACCESS_TOKEN_EXPIRATION); + Date refreshExp = new Date(now.getTime() + REFRESH_TOKEN_EXPIRATION); + + // 토큰 생성 + return new JwtVo( + createToken(payloads, accessExp, "access"), + createToken(payloads, refreshExp, "refresh") + ); + } + + // JWT 생성 λ©”μ„œλ“œ + private String createToken(Map payloads, Date expiration, String subject) { + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setClaims(payloads) + .setIssuer(ISSUER) + .setIssuedAt(new Date()) + .setExpiration(expiration) + .setSubject(subject) + .signWith(SignatureAlgorithm.HS256, JWT_SECRET_KEY.getBytes()) + .compact(); + } + + // 토큰을 κ²€μ¦ν•˜κ³  μœ νš¨ν•œ μ‚¬μš©μž 정보λ₯Ό λ°˜ν™˜ + @Transactional(readOnly = true) + public User validateToken(boolean isAccessToken, String header) throws AppException { + // Jwt 토큰 및 claims μΆ”μΆœ + String token = extractToken(header); + Claims claims = parseToken(token); + + // μ‚¬μš©μž 정보 μΆ”μΆœ 및 λ§€ν•‘ + Long userId = claims.get(PAYLOAD_KEY_ID, Long.class); + User user = userRepository.findById(userId) + .orElseThrow(() -> new AppException(GlobalErrorCode.USER_NOT_FOUND)); + + if (!isAccessToken) { + // refreshToken μžˆλŠ”μ§€ 확인 + String storedRefreshToken = redisUtil.getOpsForValue(user.getId() + "_refresh"); + if (storedRefreshToken == null || !storedRefreshToken.equals(token)) { + throw new AppException(GlobalErrorCode.AUTHORIZATION_FAILED); + } + } + + return user; + } + + // Authorization ν—€λ”μ—μ„œ Bearer 토큰 μΆ”μΆœ + private String extractToken(String header) { + if (header == null || !header.startsWith("Bearer ")) { + throw new AppException(GlobalErrorCode.INVALID_TOKEN); + } + return header.substring(7); + } + + // JWT ν† ν°μ—μ„œ ν΄λ ˆμž„(Claims) μΆ”μΆœ + private Claims parseToken(String token) { + try { + return Jwts.parser() + .setSigningKey(JWT_SECRET_KEY.getBytes()) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (JwtException e) { + throw new AppException(GlobalErrorCode.INVALID_TOKEN); + } + } + +} diff --git a/src/main/java/com/edu/kobridge/global/util/RedisUtil.java b/src/main/java/com/edu/kobridge/global/util/RedisUtil.java new file mode 100644 index 0000000..6228dcf --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/util/RedisUtil.java @@ -0,0 +1,38 @@ +package com.edu.kobridge.global.util; + +import java.util.concurrent.TimeUnit; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class RedisUtil { + private final RedisTemplate redisTemplate; + + // λ ˆλ””μŠ€μ— 유효 μ‹œκ°„μ— 맞좰 κ°’ μ €μž₯ + public void setOpsForValue(String key, String value, int expire_h) { + ValueOperations stringValueOperations = redisTemplate.opsForValue(); + stringValueOperations.set(key, value); + redisTemplate.expire(key, expire_h, TimeUnit.HOURS); + + log.info("[Redis] Data saved successfully. -- " + key); + } + + // λ ˆλ””μŠ€μ— μ €μž₯된 κ°’ κ°€μ Έμ˜€κΈ° + public String getOpsForValue(String key) { + return (String)redisTemplate.opsForValue().get(key); + } + + // λ ˆλ””μŠ€μ— μ €μž₯된 κ°’ μ‚­μ œ + public void delete(String key) { + redisTemplate.delete(key); + + log.info("[Redis] Data deleted successfully. -- " + key); + } +} diff --git a/src/main/java/com/edu/kobridge/global/util/ResponseUtil.java b/src/main/java/com/edu/kobridge/global/util/ResponseUtil.java new file mode 100644 index 0000000..690f677 --- /dev/null +++ b/src/main/java/com/edu/kobridge/global/util/ResponseUtil.java @@ -0,0 +1,39 @@ +package com.edu.kobridge.global.util; + +import java.io.IOException; + +import org.springframework.stereotype.Component; + +import com.edu.kobridge.global.common.DataResponseDto; +import com.edu.kobridge.global.common.ResponseDto; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@RequiredArgsConstructor +public class ResponseUtil { + + // λ‹¨μˆœ λ©”μ‹œμ§€ 응닡을 μ„€μ • + public static void setResponse(HttpServletResponse response, int status, String message) throws IOException { + ResponseDto dto = ResponseDto.of(status, message); + + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + response.setStatus(status); + response.getWriter().write(new ObjectMapper().writeValueAsString(dto)); + } + + // 데이터가 ν¬ν•¨λœ 응닡을 μ„€μ • + public static void setDataResponse(HttpServletResponse response, int status, Object data) throws IOException { + ResponseDto dto = DataResponseDto.of(data, status); + + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + response.setStatus(status); + response.getWriter().write(new ObjectMapper().writeValueAsString(dto)); + } +} diff --git a/src/main/java/com/edu/kobridge/user/controller/UserController.java b/src/main/java/com/edu/kobridge/user/controller/UserController.java new file mode 100644 index 0000000..7cc6348 --- /dev/null +++ b/src/main/java/com/edu/kobridge/user/controller/UserController.java @@ -0,0 +1,75 @@ +package com.edu.kobridge.user.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.edu.kobridge.global.common.DataResponseDto; +import com.edu.kobridge.global.common.ResponseDto; +import com.edu.kobridge.global.enums.LangType; +import com.edu.kobridge.user.domain.entity.User; +import com.edu.kobridge.user.dto.req.SignUpReqDto; +import com.edu.kobridge.user.dto.res.LoginResDto; +import com.edu.kobridge.user.service.UserService; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequestMapping("/api/user") +@RequiredArgsConstructor +@Slf4j +public class UserController implements UserControllerDocs { + private final UserService userService; + + @GetMapping("/google-login") + public ResponseEntity getGoogleLogin(@RequestHeader("id-token") String idToken) { + LoginResDto resDto = userService.getGoogleLogin(idToken); + return ResponseEntity.status(201).body(DataResponseDto.of(resDto, 201)); + } + + @GetMapping("/token") + public ResponseEntity getAccessToken(@RequestHeader("Authorization-Refresh") String refreshToken) { + LoginResDto resDto = userService.getAccessToken(refreshToken); + return ResponseEntity.status(201).body(DataResponseDto.of(resDto, 201)); + } + + @PostMapping + public ResponseEntity postSignUp(@RequestAttribute("user") User user, + @RequestBody @Valid SignUpReqDto signUpReq) { + userService.postSignUp(user, signUpReq); + return ResponseEntity.ok(ResponseDto.of(200)); + } + + @GetMapping + public ResponseEntity getUserInfo(@RequestAttribute("user") User user) { + return ResponseEntity.status(200).body(DataResponseDto.of(userService.getUserInfo(user), 200)); + } + + @PatchMapping("/lang") + public ResponseEntity patchLang(@RequestAttribute("user") User user, LangType lang) { + userService.updateLang(user, lang); + return ResponseEntity.ok(ResponseDto.of(200)); + } + + @PatchMapping("level") + public ResponseEntity patchLevel(@RequestAttribute("user") User user) { + userService.updateLevel(user); + return ResponseEntity.ok(ResponseDto.of(200)); + } + + @DeleteMapping("/google-logout") + public ResponseEntity deleteGoogleLogout(@RequestAttribute("user") User user) { + userService.deleteGoogleLogout(user); + return ResponseEntity.ok(ResponseDto.of(200)); + } + +} diff --git a/src/main/java/com/edu/kobridge/user/controller/UserControllerDocs.java b/src/main/java/com/edu/kobridge/user/controller/UserControllerDocs.java new file mode 100644 index 0000000..a96ff63 --- /dev/null +++ b/src/main/java/com/edu/kobridge/user/controller/UserControllerDocs.java @@ -0,0 +1,269 @@ +package com.edu.kobridge.user.controller; + +import org.springframework.http.ResponseEntity; + +import com.edu.kobridge.global.common.ResponseDto; +import com.edu.kobridge.global.enums.LangType; +import com.edu.kobridge.user.domain.entity.User; +import com.edu.kobridge.user.dto.req.SignUpReqDto; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +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; + +@Tag(name = "User", description = "μ‚¬μš©μž κ΄€λ ¨ API") +public interface UserControllerDocs { + + @Operation(summary = "둜그인", description = "ꡬ글 λ‘œκ·ΈμΈμ„ μ§„ν–‰ν•©λ‹ˆλ‹€.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Created", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{ \"code\": 201, \"message\": \"Created\" }") + ) + ), + @ApiResponse(responseCode = "400", description = "잘λͺ»λœ μš”μ²­μž…λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "{ \"code\": 400, \"message\": \"Id Token이 ν•„μš”ν•©λ‹ˆλ‹€.\" }," + ) + ) + ), + @ApiResponse(responseCode = "401", description = "인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "[" + + "{ \"code\": 401, \"message\": \"인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Token이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Access Token이 ν•„μš”ν•©λ‹ˆλ‹€.\" }," + + "{ \"code\": 404, \"message\": \"ν•΄λ‹Ήν•˜λŠ” μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.\" }" + + "]" + ) + ) + ), + @ApiResponse(responseCode = "403", description = "접근이 ν—ˆμš©λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "{ \"code\": 403, \"message\": \"Token이 λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.\" }") + ) + ), + @ApiResponse(responseCode = "404", description = "ν•΄λ‹Ή μžμ›μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "{ \"code\": 404, \"message\": \"ν•΄λ‹Ήν•˜λŠ” μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.\" }") + ) + ) + }) + ResponseEntity getGoogleLogin(String idToken); + + @Operation(summary = "accessToken λ°œκΈ‰", description = "λ¦¬ν”„λ ˆμ‹œ 토큰을 μ΄μš©ν•΄ μ—‘μ„ΈμŠ€ 토큰을 λ°œκΈ‰ν•©λ‹ˆλ‹€.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Created", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{ \"code\": 201, \"message\": \"Created\" }") + ) + ), + @ApiResponse(responseCode = "401", description = "인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "[" + + "{ \"code\": 401, \"message\": \"인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Token이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Refresh Token이 ν•„μš”ν•©λ‹ˆλ‹€.\" }" + + "]" + ) + ) + ), + @ApiResponse(responseCode = "403", description = "접근이 ν—ˆμš©λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "{ \"code\": 403, \"message\": \"Token이 λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.\" }") + ) + ) + }) + ResponseEntity getAccessToken(String refreshToken); + + @Operation(summary = "νšŒμ›κ°€μž…", description = "μ‚¬μš©μž νšŒμ›κ°€μž…μ„ μ§„ν–‰ν•©λ‹ˆλ‹€.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{ \"code\": 200, \"message\": \"Ok\" }") + ) + ), + @ApiResponse(responseCode = "400", description = "잘λͺ»λœ μš”μ²­μž…λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "[" + + "{ \"code\": 400, \"message\": \"이름은 ν•„μˆ˜ κ°’μž…λ‹ˆλ‹€.\" }, " + + "{ \"code\": 400, \"message\": \"이름은 2μžμ΄μƒ 50자 λ―Έλ§Œμ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€.\" }," + + "{ \"code\": 400, \"message\": \"λ‚˜μ΄λŠ” 1 이상이어야 ν•©λ‹ˆλ‹€.\" }," + + "{ \"code\": 400, \"message\": \"λ‚˜μ΄λŠ” 100 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.\" }," + + "{ \"code\": 400, \"message\": \"역할은 ν•„μˆ˜ κ°’μž…λ‹ˆλ‹€.\" }," + + "{ \"code\": 400, \"message\": \"ν•™κ΅λŠ” ν•„μˆ˜ κ°’μž…λ‹ˆλ‹€.\" }," + + "{ \"code\": 400, \"message\": \"학년은 1 이상이어야 ν•©λ‹ˆλ‹€.\" }," + + "{ \"code\": 400, \"message\": \"학년은 6 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.\" }" + + "{ \"code\": 400, \"message\": \"쀑학생, 고등학생은 3ν•™λ…„κΉŒμ§€λ§Œ μ‘΄μž¬ν•©λ‹ˆλ‹€.\" }" + + "{ \"code\": 400, \"message\": \"μŒμ„±μ€ ν•„μˆ˜ κ°’μž…λ‹ˆλ‹€.\" }" + + "]" + ) + ) + ), + @ApiResponse(responseCode = "401", description = "인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "[" + + "{ \"code\": 401, \"message\": \"인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Token이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Access Token이 ν•„μš”ν•©λ‹ˆλ‹€.\" }" + + "]" + ) + ) + ), + @ApiResponse(responseCode = "403", description = "접근이 ν—ˆμš©λ˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "{ \"code\": 403, \"message\": \"Token이 λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.\" }") + ) + ) + }) + ResponseEntity postSignUp(User user, SignUpReqDto signUpReq); + + @Operation(summary = "μ‚¬μš©μž 정보 확인", description = "μ‚¬μš©μžμ˜ 이름과 λ²ˆμ—­ μ–Έμ–΄, λ ˆλ²¨μ„ ν™•μΈν•©λ‹ˆλ‹€.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{\n" + + " \"code\": 200,\n" + + " \"message\": \"OK\",\n" + + " \"data\": {\n" + + " \"name\": \"κΉ€μ² μˆ˜\",\n" + + " \"lang\": \"ENG\",\n" + + " \"level\": 1\n" + + " }\n" + + "}") + ) + ), + @ApiResponse(responseCode = "401", description = "인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "[" + + "{ \"code\": 401, \"message\": \"인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Token이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Access Token이 ν•„μš”ν•©λ‹ˆλ‹€.\" }" + + "]" + ) + ) + ) + }) + ResponseEntity getUserInfo(User user); + + @Operation(summary = "λ²ˆμ—­ μ–Έμ–΄ λ³€κ²½", description = "μ‚¬μš©ν•˜λŠ” λ²ˆμ—­ μ–Έμ–΄λ₯Ό λ³€κ²½ν•©λ‹ˆλ‹€.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{ \"code\": 200, \"message\": \"Ok\" }") + ) + ), + @ApiResponse(responseCode = "400", description = "잘λͺ»λœ μš”μ²­μž…λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "{ \"code\": 400, \"message\": \"LANG_TYPE 의 값이 μ•„λ‹™λ‹ˆλ‹€.\" }" + ) + ) + ), + @ApiResponse(responseCode = "401", description = "인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "[" + + "{ \"code\": 401, \"message\": \"인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Token이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Access Token이 ν•„μš”ν•©λ‹ˆλ‹€.\" }" + + "]" + ) + ) + ) + }) + ResponseEntity patchLang(User user, LangType lang); + + @Operation(summary = "레벨 μ™„λ£Œ", description = "λ ˆλ²¨μ„ μ™„λ£Œν•˜μ—¬ 1λ‹¨κ²Œ λ†’μ•„μ§‘λ‹ˆλ‹€.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{ \"code\": 200, \"message\": \"Ok\" }") + ) + ), + @ApiResponse(responseCode = "401", description = "인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "[" + + "{ \"code\": 401, \"message\": \"인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Token이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Access Token이 ν•„μš”ν•©λ‹ˆλ‹€.\" }" + + "]" + ) + ) + ) + }) + ResponseEntity patchLevel(User user); + + @Operation(summary = "λ‘œκ·Έμ•„μ›ƒ", description = "λ‘œκ·Έμ•„μ›ƒμ„ μ§„ν–‰ν•©λ‹ˆλ‹€.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Ok", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{ \"code\": 200, \"message\": \"Ok\" }") + ) + ), + @ApiResponse(responseCode = "401", description = "인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = @ExampleObject(value = "[" + + "{ \"code\": 401, \"message\": \"인증에 μ‹€νŒ¨ν•˜μ˜€μŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Token이 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€.\" }," + + "{ \"code\": 401, \"message\": \"Access Token이 ν•„μš”ν•©λ‹ˆλ‹€.\" }" + + "]" + ) + ) + ) + }) + ResponseEntity deleteGoogleLogout(User user); + +} + diff --git a/src/main/java/com/edu/kobridge/user/domain/entity/User.java b/src/main/java/com/edu/kobridge/user/domain/entity/User.java new file mode 100644 index 0000000..8f594ba --- /dev/null +++ b/src/main/java/com/edu/kobridge/user/domain/entity/User.java @@ -0,0 +1,82 @@ +package com.edu.kobridge.user.domain.entity; + +import com.edu.kobridge.global.common.BaseTime; +import com.edu.kobridge.global.enums.LangType; +import com.edu.kobridge.global.enums.SchoolType; +import com.edu.kobridge.global.enums.UserRoleType; +import com.edu.kobridge.global.enums.VoiceType; + +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.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class User extends BaseTime { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotNull + @Column(unique = true) + private String email; + + private String name; + + @Enumerated(EnumType.STRING) + private LangType lang; + + @Enumerated(EnumType.STRING) + private SchoolType school; + + private byte grade; + + @Enumerated(EnumType.STRING) + private VoiceType voice; + + private int level; + + @NotNull + @Enumerated(EnumType.STRING) + private UserRoleType role; + + public static User of(@NotNull String email) { + return User + .builder() + .email(email) + .role(UserRoleType.USER) + .build(); + } + + public void updateInfo(@NotNull String name, @NotNull LangType lang, @NotNull SchoolType school, + @NotNull byte grade, @NotNull VoiceType voice) { + this.name = name; + this.lang = lang; + this.school = school; + this.grade = grade; + this.voice = voice; + this.level = 1; + } + + public void updateLang(@NotNull LangType lang) { + this.lang = lang; + } + + public void updateLevel(@NotNull int level) { + this.level = level; + } +} diff --git a/src/main/java/com/edu/kobridge/user/domain/repository/UserRepository.java b/src/main/java/com/edu/kobridge/user/domain/repository/UserRepository.java new file mode 100644 index 0000000..c03bda2 --- /dev/null +++ b/src/main/java/com/edu/kobridge/user/domain/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.edu.kobridge.user.domain.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.edu.kobridge.user.domain.entity.User; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/src/main/java/com/edu/kobridge/user/dto/req/SignUpReqDto.java b/src/main/java/com/edu/kobridge/user/dto/req/SignUpReqDto.java new file mode 100644 index 0000000..8569fa4 --- /dev/null +++ b/src/main/java/com/edu/kobridge/user/dto/req/SignUpReqDto.java @@ -0,0 +1,43 @@ +package com.edu.kobridge.user.dto.req; + +import com.edu.kobridge.global.enums.LangType; +import com.edu.kobridge.global.enums.SchoolType; +import com.edu.kobridge.global.enums.VoiceType; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Schema(description = "μ‚¬μš©μž κ°€μž… μš”μ²­ DTO") +public record SignUpReqDto( + @Schema(description = "이름", example = "κΉ€μ² μˆ˜") + @NotBlank(message = "이름은 ν•„μˆ˜ κ°’μž…λ‹ˆλ‹€.") + @Size(min = 2, max = 50, message = "이름은 2μžμ΄μƒ 50자 λ―Έλ§Œμ΄μ–΄μ•Ό ν•©λ‹ˆλ‹€.") + String name, + + @Schema(description = "λ‚˜μ΄", example = "23") + @Min(value = 1, message = "λ‚˜μ΄λŠ” 1 이상이어야 ν•©λ‹ˆλ‹€.") + @Max(value = 100, message = "λ‚˜μ΄λŠ” 100 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") + int age, + + @Schema(description = "μ–Έμ–΄", example = "ENG , VET, CHN, JPN, NONE") + @NotNull(message = "역할은 ν•„μˆ˜ κ°’μž…λ‹ˆλ‹€.") + LangType lang, + + @Schema(description = "학ꡐ", example = "ELEMENTARY , MIDDLE, HIGH") + @NotNull(message = "ν•™κ΅λŠ” ν•„μˆ˜ κ°’μž…λ‹ˆλ‹€.") + SchoolType school, + + @Schema(description = "ν•™λ…„", example = "3") + @Min(value = 1, message = "학년은 1 이상이어야 ν•©λ‹ˆλ‹€.") + @Max(value = 6, message = "학년은 6 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.") + byte grade, + + @Schema(description = "μŒμ„±", example = "BASIC, CUSTOM") + @NotNull(message = "μŒμ„±μ€ ν•„μˆ˜ κ°’μž…λ‹ˆλ‹€.") + VoiceType voice +) { +} diff --git a/src/main/java/com/edu/kobridge/user/dto/res/LoginResDto.java b/src/main/java/com/edu/kobridge/user/dto/res/LoginResDto.java new file mode 100644 index 0000000..e059245 --- /dev/null +++ b/src/main/java/com/edu/kobridge/user/dto/res/LoginResDto.java @@ -0,0 +1,27 @@ +package com.edu.kobridge.user.dto.res; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class LoginResDto { + @Schema(description = "accessToken", example = "eyJ0eXAiOiJKV1QiLCJhbGc...") + private final String accessToken; + + @Schema(description = "refreshToken", example = "eyJ0eXAiOiJKV1QiLCJhbGc...") + private final String refreshToken; + + @Schema(description = "isFirstLogin : μ‚¬μš©μžκ°€ νšŒμ›κ°€μž…ν•œ 적이 μ—†λ‹€λ©΄ false λ°˜ν™˜ / μ‚¬μš©μžκ°€ νšŒμ›κ°€μž…ν•œ 적이 μžˆλ‹€λ©΄ true λ°˜ν™˜", example = "false") + private final boolean isFirstLogin; + + public static LoginResDto of(String accessToken, String refreshToken, boolean isFirstLogin) { + return LoginResDto.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .isFirstLogin(isFirstLogin) + .build(); + } +} diff --git a/src/main/java/com/edu/kobridge/user/dto/res/UserResDto.java b/src/main/java/com/edu/kobridge/user/dto/res/UserResDto.java new file mode 100644 index 0000000..ce76ee0 --- /dev/null +++ b/src/main/java/com/edu/kobridge/user/dto/res/UserResDto.java @@ -0,0 +1,36 @@ +package com.edu.kobridge.user.dto.res; + +import com.edu.kobridge.global.enums.LangType; +import com.edu.kobridge.global.enums.VoiceType; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Schema(description = "μ‚¬μš©μž 간단 정보 DTO") +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class UserResDto { + + @Schema(description = "이름", example = "κΉ€μ² μˆ˜") + private final String name; + + @Schema(description = "λ²ˆμ—­ μ–Έμ–΄", example = "ENG") + private final LangType lang; + + @Schema(description = "μ„ νƒν•œ Voice", example = "ONE") + private final VoiceType voice; + + @Schema(description = "ν˜„μž¬ level", example = "3") + private final int level; + + public static UserResDto of(String name, LangType lang, VoiceType voice, int level) { + return UserResDto.builder() + .name(name) + .lang(lang) + .voice(voice) + .level(level) + .build(); + } +} diff --git a/src/main/java/com/edu/kobridge/user/error/UserErrorCode.java b/src/main/java/com/edu/kobridge/user/error/UserErrorCode.java new file mode 100644 index 0000000..12b37e2 --- /dev/null +++ b/src/main/java/com/edu/kobridge/user/error/UserErrorCode.java @@ -0,0 +1,22 @@ +package com.edu.kobridge.user.error; + +import org.springframework.http.HttpStatus; + +import com.edu.kobridge.global.error.ErrorCode; + +import lombok.Getter; + +@Getter +public enum UserErrorCode implements ErrorCode { + ID_TOKEN_REQUIRED(HttpStatus.BAD_REQUEST, "Id Token이 ν•„μš”ν•©λ‹ˆλ‹€."), + INVALID_ID_TOKEN(HttpStatus.UNAUTHORIZED, "μœ νš¨ν•œ Id Token이 μ•„λ‹™λ‹ˆλ‹€."), + GRADE_SIZE_ERROR(HttpStatus.BAD_REQUEST, "쀑학생, 고등학생은 3ν•™λ…„κΉŒμ§€λ§Œ μ‘΄μž¬ν•©λ‹ˆλ‹€."); + + private final HttpStatus httpStatus; + private final String message; + + UserErrorCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } +} diff --git a/src/main/java/com/edu/kobridge/user/service/UserService.java b/src/main/java/com/edu/kobridge/user/service/UserService.java new file mode 100644 index 0000000..9ff5e85 --- /dev/null +++ b/src/main/java/com/edu/kobridge/user/service/UserService.java @@ -0,0 +1,127 @@ +package com.edu.kobridge.user.service; + +import java.io.IOException; +import java.security.GeneralSecurityException; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.edu.kobridge.global.enums.JwtVo; +import com.edu.kobridge.global.enums.LangType; +import com.edu.kobridge.global.enums.SchoolType; +import com.edu.kobridge.global.error.ErrorCode; +import com.edu.kobridge.global.error.GlobalErrorCode; +import com.edu.kobridge.global.error.exception.AppException; +import com.edu.kobridge.global.util.GoogleOAuthUtil; +import com.edu.kobridge.global.util.JwtUtil; +import com.edu.kobridge.global.util.RedisUtil; +import com.edu.kobridge.user.domain.entity.User; +import com.edu.kobridge.user.domain.repository.UserRepository; +import com.edu.kobridge.user.dto.req.SignUpReqDto; +import com.edu.kobridge.user.dto.res.LoginResDto; +import com.edu.kobridge.user.dto.res.UserResDto; +import com.edu.kobridge.user.error.UserErrorCode; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class UserService { + private final JwtUtil jwtUtil; + private final RedisUtil redisUtil; + private final GoogleOAuthUtil googleOAuthUtil; + private final UserRepository userRepository; + + @Transactional + public LoginResDto getGoogleLogin(String idToken) { + // μš”μ²­ 인자 μœ νš¨μ„± 검사 + if (idToken.isBlank()) { + throw new AppException(UserErrorCode.ID_TOKEN_REQUIRED); + } + + // ꡬ글 인증 μ§„ν–‰ + User googleUser = null; + try { + googleUser = googleOAuthUtil.authenticate(idToken); + } catch (GeneralSecurityException | IOException e) { + throw new AppException(UserErrorCode.INVALID_ID_TOKEN); + } + + // μœ μ € μ°Ύκ³ , μ—†λ‹€λ©΄ μƒˆ μœ μ € 생성 + User finalGoogleUser = googleUser; + User user = userRepository.findByEmail(googleUser.getEmail()) + .orElseGet(() -> userRepository.save(finalGoogleUser)); + + // JWT 토큰 생성 및 refreshToken μ €μž₯ + JwtVo jwtVo = jwtUtil.generateTokens(user); + redisUtil.setOpsForValue(user.getId() + "_refresh", jwtVo.getRefreshToken(), + jwtUtil.getREFRESH_TOKEN_EXPIRATION()); + + return LoginResDto.of(jwtVo.getAccessToken(), jwtVo.getRefreshToken(), user.getLang() == null); + } + + @Transactional + public LoginResDto getAccessToken(String refreshToken) { + if (refreshToken.isBlank()) { + throw new AppException(GlobalErrorCode.REFRESH_TOKEN_REQUIRED); + } + + // refreshToken μœ νš¨μ„± 검사 μ‹€ν–‰ + User tokenUser; + try { + tokenUser = jwtUtil.validateToken(false, refreshToken); + } catch (JwtException e) { + ErrorCode code = + e instanceof ExpiredJwtException ? GlobalErrorCode.EXPIRED_JWT : GlobalErrorCode.INVALID_TOKEN; + + throw new AppException(code); + } + + // JWT 토큰 생성 및 refreshToken μ €μž₯ + JwtVo jwtVo = jwtUtil.generateTokens(tokenUser); + redisUtil.setOpsForValue(tokenUser.getId() + "_refresh", jwtVo.getRefreshToken(), + jwtUtil.getREFRESH_TOKEN_EXPIRATION()); + + return LoginResDto.of(jwtVo.getAccessToken(), jwtVo.getRefreshToken(), tokenUser.getLang() == null); + } + + @Transactional + public void postSignUp(User user, SignUpReqDto signUpReq) { + // ν•™λ…„ μœ νš¨μ„± 검사 + if (signUpReq.school() == SchoolType.MIDDLE || signUpReq.school() == SchoolType.HIGH || signUpReq.grade() > 3) { + throw new AppException(UserErrorCode.GRADE_SIZE_ERROR); + } + + // μ‚¬μš©μž 정보 μ—…λ°μ΄νŠΈ + user.updateInfo(signUpReq.name(), signUpReq.lang(), signUpReq.school(), signUpReq.grade(), signUpReq.voice()); + + userRepository.save(user); + } + + public UserResDto getUserInfo(User user) { + return UserResDto.of(user.getName(), user.getLang(), user.getVoice(), user.getLevel()); + } + + @Transactional + public void updateLang(User user, LangType lang) { + user.updateLang(lang); + userRepository.save(user); + } + + @Transactional + public void updateLevel(User user) { + user.updateLevel(user.getLevel() + 1); + userRepository.save(user); + } + + @Transactional + public void deleteGoogleLogout(User user) { + // μ‚¬μš©μž refreshToken μ‚­μ œ + redisUtil.delete(user.getId() + "_refresh"); + } +}