diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c793346 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ + +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### DB Store ### +docker-test-db/store + +## docker-compose.yml ### +docker-compose.yml + +## env ## +.env \ No newline at end of file diff --git a/README.md b/README.md index c0896da..8a315cb 100644 --- a/README.md +++ b/README.md @@ -9,5 +9,4 @@ docker images ### 도커 컨테이너 확인 docker ps ### 도커 Java Log 확인 -docker logs -f [server hash number] - +docker logs -f [server hash number] \ No newline at end of file diff --git a/docker-test-server/Dockerfile b/docker-test-server/Dockerfile new file mode 100644 index 0000000..cca97f1 --- /dev/null +++ b/docker-test-server/Dockerfile @@ -0,0 +1,18 @@ +FROM openjdk:11-jdk-slim + +WORKDIR /app + +# COPY만 docker-compose 파일의 위치를 기반으로 작동함 +COPY . . + +# 개행문자 오류 해결 [unix와 window 시스템 차이] +RUN sed -i 's/\r$//' gradlew + +# RUN은 현재 파일을 위치를 기반으로 작동함 +RUN chmod +x ./gradlew +RUN ./gradlew clean build + +ENV JAR_PATH=/app/build/libs +RUN mv ${JAR_PATH}/*.jar /app/app.jar + +ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "app.jar"] \ No newline at end of file diff --git a/docker-test-server/build.gradle b/docker-test-server/build.gradle new file mode 100644 index 0000000..c0b13a3 --- /dev/null +++ b/docker-test-server/build.gradle @@ -0,0 +1,47 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '2.7.12' + id 'io.spring.dependency-management' version '1.0.15.RELEASE' +} + +group = 'com.example' +version = '1.0' + +java { + sourceCompatibility = '11' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'io.github.cdimascio:java-dotenv:+' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + implementation 'org.json:json:20171018' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + runtimeOnly 'com.h2database:h2' +} + +tasks.named('test') { + systemProperty 'file.encoding', 'UTF-8' + useJUnitPlatform() +} + +jar { + enabled = false +} \ No newline at end of file diff --git a/docker-test-server/gradle/wrapper/gradle-wrapper.jar b/docker-test-server/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/docker-test-server/gradle/wrapper/gradle-wrapper.jar differ diff --git a/docker-test-server/gradle/wrapper/gradle-wrapper.properties b/docker-test-server/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..774fae8 --- /dev/null +++ b/docker-test-server/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/docker-test-server/gradlew b/docker-test-server/gradlew new file mode 100644 index 0000000..a69d9cb --- /dev/null +++ b/docker-test-server/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/docker-test-server/gradlew.bat b/docker-test-server/gradlew.bat new file mode 100644 index 0000000..f127cfd --- /dev/null +++ b/docker-test-server/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/docker-test-server/settings.gradle b/docker-test-server/settings.gradle new file mode 100644 index 0000000..0daf60f --- /dev/null +++ b/docker-test-server/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'docker-test-server' diff --git a/docker-test-server/sql/init.sql b/docker-test-server/sql/init.sql new file mode 100644 index 0000000..c2f588b --- /dev/null +++ b/docker-test-server/sql/init.sql @@ -0,0 +1,13 @@ +CREATE DATABASE IF NOT EXISTS toothFairy; +USE toothFairy; + +CREATE TABLE user_tb ( + email VARCHAR(255) PRIMARY KEY, + pet_name VARCHAR(50), + pet_weight INT, + access_token VARCHAR(255), + refresh_token VARCHAR(255) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO user_tb (email, pet_name, pet_weight, access_token, refresh_token) +VALUES ('test@gmail.com', 'Charlie', 12, 'test_access_token', 'test_refresh_token'); \ No newline at end of file diff --git a/docker-test-server/src/main/java/com/example/server/DemoApplication.java b/docker-test-server/src/main/java/com/example/server/DemoApplication.java new file mode 100644 index 0000000..f242f88 --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/DemoApplication.java @@ -0,0 +1,13 @@ +package com.example.server; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DemoApplication { + + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } + +} diff --git a/docker-test-server/src/main/java/com/example/server/error/CustomErrorController.java b/docker-test-server/src/main/java/com/example/server/error/CustomErrorController.java new file mode 100644 index 0000000..109dae3 --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/error/CustomErrorController.java @@ -0,0 +1,66 @@ +package com.example.server.error; + +import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import javax.servlet.RequestDispatcher; +import javax.servlet.http.HttpServletRequest; + +@Controller +public class CustomErrorController implements ErrorController { + @RequestMapping("/error") + @ResponseBody + public ResponseEntity handleError(HttpServletRequest request) { + Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); + ErrorResponse errorResponse; + + if (status != null) { + int statusCode = Integer.valueOf(status.toString()); + + switch (statusCode) { + case 500: + errorResponse = ErrorResponse.builder() + .status(500) + .message("서버 내부 오류") + .code("INTERNAL_SERVER_ERROR") + .build(); + return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + + case 401: + errorResponse = ErrorResponse.builder() + .status(401) + .message("유효하지 않은 토큰") + .code("UNAUTHORIZED") + .build(); + return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); + + case 400: + errorResponse = ErrorResponse.builder() + .status(400) + .message("잘못된 액세스토큰 형식") + .code("WRONG_ACCESSTOKEN") + .build(); + return new ResponseEntity<>(errorResponse, HttpStatus.PAYMENT_REQUIRED); + + default: + errorResponse = ErrorResponse.builder() + .status(statusCode) + .message("알 수 없는 오류가 발생했습니다") + .code("UNKNOWN_ERROR") + .build(); + return new ResponseEntity<>(errorResponse, HttpStatus.valueOf(statusCode)); + } + } + + errorResponse = ErrorResponse.builder() + .status(500) + .message("알 수 없는 오류가 발생했습니다") + .code("UNKNOWN_ERROR") + .build(); + return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } +} \ No newline at end of file diff --git a/docker-test-server/src/main/java/com/example/server/error/ErrorResponse.java b/docker-test-server/src/main/java/com/example/server/error/ErrorResponse.java new file mode 100644 index 0000000..09503ff --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/error/ErrorResponse.java @@ -0,0 +1,12 @@ +package com.example.server.error; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ErrorResponse { + private final int status; + private final String message; + private final String code; +} \ No newline at end of file diff --git a/docker-test-server/src/main/java/com/example/server/home/HomeController.java b/docker-test-server/src/main/java/com/example/server/home/HomeController.java new file mode 100644 index 0000000..7a31c35 --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/home/HomeController.java @@ -0,0 +1,47 @@ +package com.example.server.home; + +import org.springframework.web.bind.annotation.RestController; + +import com.example.server.kakao.KakaoApi; + +import java.util.HashMap; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/home") +public class HomeController { + private final KakaoApi kakaoApi = new KakaoApi(); + + // 홈 화면 + // 1. 토큰 확인. + // 2. 토큰 만료시 재발급. + // 3. 리프레시 토큰 또한 만료시 로그인 화면 이동. + @GetMapping("") + public ResponseEntity home(@RequestHeader("accesstoken") String accesstoken, + @RequestHeader("refreshtoken") String refreshtoken) + throws Exception { + + HashMap map = kakaoApi.validateToken(accesstoken, refreshtoken); + + if (map.get("status").equals("false")) { + return ResponseEntity + .status(401) + .header("Location", "/api/user/login") + .build(); + } + + return ResponseEntity + .status(200) + .header("AccessToken", map.get("accessTokenJWT")) + .header("RefreshToken", map.get("refreshTokenJWT")) + .header("Location", "") + .build(); + } +} diff --git a/docker-test-server/src/main/java/com/example/server/jwt/JwtProvider.java b/docker-test-server/src/main/java/com/example/server/jwt/JwtProvider.java new file mode 100644 index 0000000..0467688 --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/jwt/JwtProvider.java @@ -0,0 +1,71 @@ +package com.example.server.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.*; +import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Value; + +import java.nio.charset.StandardCharsets; +import java.security.Key; + +import javax.crypto.SecretKey; + +@Component +public class JwtProvider { + @Value("${key.jwt-secret-key}") + private String secretKey; + + + + private Key getSigningKey() { + byte[] keyBytes = this.secretKey.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + // JWT 토큰에서 모든 클레임 추출 + private Claims getAllClaimsFromToken(String token) { + SecretKey key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } + + // 문자열을 JWT로 암호화. + public String createJWTToken(String payload) { + return Jwts.builder() + .setSubject("JWT") // 예시로 "JWT" 사용 + .claim("data", payload) + .signWith(getSigningKey()) + .compact(); + } + + // JWT를 다시 문자열로 비암호화 + public String decodeJWTToken(String jwtToken) { + Claims claims; + try { + claims = getAllClaimsFromToken(jwtToken); + } catch (Exception e) { + return ""; + } + return claims.get("data", String.class); + } + + // JWT 토큰을 검증하고 Kakao토큰 추출. + public String validateAndGetPayload(String token) { + Claims claims; + + try { + claims = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (Exception e) { + return null; + } + + return claims.get("data", String.class); + } +} \ No newline at end of file diff --git a/docker-test-server/src/main/java/com/example/server/kakao/KakaoApi.java b/docker-test-server/src/main/java/com/example/server/kakao/KakaoApi.java new file mode 100644 index 0000000..f9c6cf8 --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/kakao/KakaoApi.java @@ -0,0 +1,157 @@ +package com.example.server.kakao; + +import java.util.HashMap; + +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import com.example.server.jwt.JwtProvider; +import com.example.server.user.UserRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class KakaoApi { + private RestTemplate restTemplate = new RestTemplate(); + @Value("{key.kakao-api-key}") + private String CLIENT_ID; + private UserRepository userRepository; + private ObjectMapper objectMapper = new ObjectMapper(); + private JwtProvider jwtProvider = new JwtProvider(); + + // 인가 코드를 받아서 accessToken을 반환 + public KakaoLoginDto getAccessToken(String code, String CLIENT_ID, String REDIRECT_URL) throws Exception { + final String apiUrl = "https://kauth.kakao.com/oauth/token"; + + MultiValueMap requestData = new LinkedMultiValueMap<>(); + requestData.add("grant_type", "authorization_code"); + requestData.add("client_id", CLIENT_ID); + requestData.add("redirect_uri", REDIRECT_URL); + requestData.add("code", code); + + ResponseEntity responseEntity = restTemplate.postForEntity(apiUrl, requestData, + String.class); + + // 에러 발생 + if (responseEntity.getStatusCode() != HttpStatus.OK) + throw new Exception(responseEntity.getStatusCode().toString()); + + JsonNode jsonNode = objectMapper.readTree(responseEntity.getBody()); + + KakaoLoginDto kakaoLoginDto = new KakaoLoginDto(); + kakaoLoginDto.setAccessToken((String) jsonNode.get("access_token").asText()); + kakaoLoginDto.setRefreshToken((String) jsonNode.get("refresh_token").asText()); + + return kakaoLoginDto; + } + + // accessToken을 받아서 사용자 정보(Email)로 반환 + public String getUserInfo(String accessToken) throws Exception { + String userInfoUrl = "https://kapi.kakao.com/v2/user/me"; + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity requestEntity = new HttpEntity<>(headers); + + ResponseEntity responseEntity = restTemplate.exchange( + userInfoUrl, + HttpMethod.GET, + requestEntity, + String.class); + + if (responseEntity.getStatusCode() != HttpStatus.OK) + throw new Exception("사용자 정보를 받아오는 중 에러."); + + JSONObject jsonObject = new JSONObject(responseEntity.getBody()); + JSONObject kakaoAccount = jsonObject.getJSONObject("kakao_account"); + + if (!kakaoAccount.has("email")) { // 로그인 화면으로 이동. + return ""; + } + + return kakaoAccount.get("email").toString(); + } + + // accessToken을 받아서 로그아웃 시키는 메서드 + public void kakaoLogout(String accessToken) throws Exception { + String logoutUrl = "https://kapi.kakao.com/v1/user/logout"; + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); // Bearer 인증 방식으로 액세스 토큰 설정 + + HttpEntity requestEntity = new HttpEntity<>(headers); + + ResponseEntity responseEntity = restTemplate.postForEntity( + logoutUrl, + requestEntity, + String.class); + + if (responseEntity.getStatusCode() != HttpStatus.OK) { + throw new RuntimeException("카카오 로그아웃 실패: " + responseEntity.getStatusCode()); + } + } + + // kakao 토큰 유효한지 확인하는 함수. + // 1-1. 만료되지X 통과 (True) + // 1-2. 만료시 refreshToken 만료체크 + // 2-1. refreshToken 만료되지X accessToken 재발급 (True) + // 2-2. refreshToken 만료시 로그인 화면 이동 (False) + public HashMap validateToken(String accessTokenJWT, String refreshTokenJWT) throws Exception { + HashMap map = new HashMap(); + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(jwtProvider.decodeJWTToken(accessTokenJWT)); // Bearer 토큰 설정 + + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity responseEntity = restTemplate.exchange( + "https://kapi.kakao.com/v1/user/access_token_info", + HttpMethod.GET, + entity, + String.class); + + if (responseEntity.getStatusCode() == HttpStatus.OK) { + map.put("status", "true"); + map.put("accessTokenJWT", accessTokenJWT); + map.put("refreshTokenJWT", refreshTokenJWT); + return map; + } + + // RefreshToken 확인 + String refreshToken = jwtProvider.decodeJWTToken(refreshTokenJWT); + String userInfoUrl = "https://kauth.kakao.com/oauth/token"; + + MultiValueMap requestData = new LinkedMultiValueMap<>(); + requestData.add("grant_type", "refresh_token"); + requestData.add("client_id", CLIENT_ID); + requestData.add("refresh_token", refreshToken); + + responseEntity = restTemplate.postForEntity(userInfoUrl, + requestData, String.class); + + if (responseEntity.getStatusCode() == HttpStatus.OK) { + JsonNode jsonNode = objectMapper.readTree(responseEntity.getBody()); + userRepository.updateUserByRefreshToken(jwtProvider.decodeJWTToken(refreshTokenJWT), + jwtProvider.createJWTToken((String) jsonNode.get("access_token").asText())); + map.put("status", "true"); + map.put("accessTokenJWT", jwtProvider.createJWTToken((String) jsonNode.get("access_token").asText())); + map.put("refreshTokenJWT", refreshTokenJWT); + return map; + } + + map.put("status", "false"); + map.put("accessTokenJWT", ""); + map.put("refreshTokenJWT", ""); + return map; + } +} \ No newline at end of file diff --git a/docker-test-server/src/main/java/com/example/server/kakao/KakaoController.java b/docker-test-server/src/main/java/com/example/server/kakao/KakaoController.java new file mode 100644 index 0000000..3ce73f3 --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/kakao/KakaoController.java @@ -0,0 +1,86 @@ +package com.example.server.kakao; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.server.jwt.JwtProvider; +import com.example.server.user.User; +import com.example.server.user.UserRepository; + +import org.springframework.web.bind.annotation.RequestMapping; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/kakao") +public class KakaoController { + private final KakaoApi kakaoApi = new KakaoApi(); + private final String REDIRECT_URL = "http://localhost:8080/api/kakao/login/oauth2/code"; + @Value("{key.kakao-api-key}") + private final String CLIENT_ID; + private final UserRepository userRepository; + private final JwtProvider jwtProvider; + + // 1. 코드 발급 받기 + @GetMapping("/getcode") + public String getcode() throws Exception { + String apiUrl = "https://kauth.kakao.com/oauth/authorize" + + "?client_id=" + CLIENT_ID + + "&redirect_uri=" + REDIRECT_URL + + "&response_type=code" + + "&scope=account_email" + + "&prompt=select_account consent"; + + return "redirect:" + apiUrl; + } + + // Kakao 버튼을 클릭시 발생. + @GetMapping("/login/oauth2/code") + public ResponseEntity kakaoLogin(@RequestParam("code") String code) throws Exception { + // 1. 코드 발급 받기 (위에 작성) + + // 2. 코드로 액세스토큰과 리프레시토큰 받기 + KakaoLoginDto kakaoLoginDto = kakaoApi.getAccessToken(code, CLIENT_ID, REDIRECT_URL); + + // 3. 사용자 정보 받기 (email) + String email = kakaoApi.getUserInfo(kakaoLoginDto.getAccessToken()); + + // 4. 이메일 형식이 다르면 login 화면으로 이동 + if (email.equals("")) { + return ResponseEntity + .status(200) + .header("Location", "/api/user/login") + .build(); + } + + // 5. 데이터베이스에서 확인, 있으면 Home으로 이동 & 갱신된 Token 저장 : 없으면 펫 설정으로 이동 & 새 Token 저장. + User user = userRepository.findByEmail(email); + + if (user == null) { + User newUser = new User(email, "", 0, kakaoLoginDto.getAccessToken(), kakaoLoginDto.getRefreshToken()); + userRepository.save(newUser); + return ResponseEntity + .status(200) + .header("Email", email) + .header("AccessToken", jwtProvider.createJWTToken(kakaoLoginDto.getAccessToken())) + .header("RefreshToken", jwtProvider.createJWTToken(kakaoLoginDto.getRefreshToken())) + .header("Location", "/api/user/petsetting") + .build(); + } + + String accessTokenJWT = jwtProvider.createJWTToken(user.getAccessToken()); + String refreshTokenJWT = jwtProvider.createJWTToken(user.getRefreshToken()); + + return ResponseEntity + .status(200) + .header("Email", email) + .header("AccessToken", accessTokenJWT) + .header("RefreshToken", refreshTokenJWT) + .header("Location", "/api/home") + .build(); + } +} diff --git a/docker-test-server/src/main/java/com/example/server/kakao/KakaoLoginDto.java b/docker-test-server/src/main/java/com/example/server/kakao/KakaoLoginDto.java new file mode 100644 index 0000000..02ed89a --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/kakao/KakaoLoginDto.java @@ -0,0 +1,33 @@ +package com.example.server.kakao; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Getter +@Setter +public class KakaoLoginDto { + public String accessToken; + public String refreshToken; + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getAccessToken() { + return accessToken; + } + + public String getRefreshToken() { + return refreshToken; + } +} \ No newline at end of file diff --git a/docker-test-server/src/main/java/com/example/server/response/Successful.java b/docker-test-server/src/main/java/com/example/server/response/Successful.java new file mode 100644 index 0000000..b5b2bf2 --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/response/Successful.java @@ -0,0 +1,33 @@ +package com.example.server.response; + +import org.json.JSONObject; + +public class Successful { + private Integer status = 200; + private String message; + private JSONObject data; + + public void setStatus(Integer status) { + this.status = status; + } + + public void setMessage(String message) { + this.message = message; + } + + public void setJsonobject(JSONObject data) { + this.data = data; + } + + public Integer getStatus() { + return status; + } + + public String getMessage() { + return message; + } + + public JSONObject getJsonobject() { + return data; + } +} diff --git a/docker-test-server/src/main/java/com/example/server/user/User.java b/docker-test-server/src/main/java/com/example/server/user/User.java new file mode 100644 index 0000000..8fcc774 --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/user/User.java @@ -0,0 +1,36 @@ +package com.example.server.user; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor // 기본 생성자 +@AllArgsConstructor // 모든 필드를 파라미터로 받는 생성자 +@Builder // Builder 패턴 +@Table(name = "user_tb") +@Entity +public class User { + @Id + private String email; + + @Column(name = "pet_name") + private String petName; + + @Column(name = "pet_weight") + private Integer petWeight; + + @Column(name = "access_token") + private String accessToken; + + @Column(name = "refresh_token") + private String refreshToken; +} \ No newline at end of file diff --git a/docker-test-server/src/main/java/com/example/server/user/UserController.java b/docker-test-server/src/main/java/com/example/server/user/UserController.java new file mode 100644 index 0000000..6055056 --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/user/UserController.java @@ -0,0 +1,102 @@ +package com.example.server.user; + +import java.util.HashMap; +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RequestMapping; + +import com.example.server.jwt.JwtProvider; +import com.example.server.kakao.KakaoApi; + +import lombok.RequiredArgsConstructor; + +// User will use +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/user") +public class UserController { + private final UserRepository userRepository; + private final JwtProvider jwtProvider = new JwtProvider(); + + @GetMapping("/database") + public ResponseEntity findAll() { + List userList = userRepository.findAll(); + return ResponseEntity.ok().body(userList); + } + + @GetMapping("/test") + public ResponseEntity test() { + return ResponseEntity.ok().body("test"); + } + + // 앱 첫 실행시. + // 1. jwt토큰을 kakako토큰으로 변환. + // 2-1. 유효하지 않은 토큰인 경우, 로그인 화면으로 이동. + // 2-2. 유효한 토큰인 경우, 재발급 후 홈화면 이동 (자동 로그인) + @GetMapping("/checktoken") + public ResponseEntity checkToken(@RequestHeader("accessToken") String accesstokenJWT, + @RequestHeader("refreshToken") String refreshtokenJWT) + throws Exception { + KakaoApi kakaoApi = new KakaoApi(); + HashMap map = kakaoApi.validateToken(accesstokenJWT, refreshtokenJWT); + + if (map.get("status").equals("false")) { + return ResponseEntity + .status(401) + .header("Location", "/api/user/login") + .build(); + } + + return ResponseEntity + .status(200) + .header("AccessToken", map.get("accessTokenJWT")) + .header("RefreshToken", map.get("refreshTokenJWT")) + .header("Location", "/api/home") + .build(); + } + + // petsetting 유저의 펫 이름과 몸무게 설정 후 헤더로 받음. 이후 데이터베이스에 업데이트 + // 헤더 : Email, 펫 이름, 펫 몸무게. + @PutMapping("/petsetting") + public ResponseEntity petSetting(@RequestHeader("email") String email, + @RequestHeader("petname") String petname, + @RequestHeader("petweight") Integer petweight) throws Exception { + + userRepository.updateUserByEmail(petname, petweight, email); + + return ResponseEntity + .status(200) + .header("Location", "/api/home") + .build(); + } + + @PatchMapping("/petsetting") + public ResponseEntity petSetting(@RequestHeader("accessToken") String accesstokenJWT, + @RequestHeader("refreshToken") String refreshtokenJWT, + @RequestHeader("petname") String petname, + @RequestHeader("petweight") Integer petweight) throws Exception { + + KakaoApi kakaoApi = new KakaoApi(); + HashMap map = kakaoApi.validateToken(accesstokenJWT, refreshtokenJWT); + + if (map.get("status").equals("false")) { + return ResponseEntity + .status(401) + .header("Location", "/api/user/login") + .build(); + } + + userRepository.updateUserByAccessToken(petname, petweight, jwtProvider.decodeJWTToken(accesstokenJWT)); + + return ResponseEntity + .status(200) + .header("Location", "") + .build(); + } +} \ No newline at end of file diff --git a/docker-test-server/src/main/java/com/example/server/user/UserRepository.java b/docker-test-server/src/main/java/com/example/server/user/UserRepository.java new file mode 100644 index 0000000..d74a50c --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/user/UserRepository.java @@ -0,0 +1,32 @@ +package com.example.server.user; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.transaction.annotation.Transactional; + +public interface UserRepository extends JpaRepository { + User findByEmail(@Param("email") String email); + + User findByAccessToken(@Param("accessToken") String accessToken); + + User findByRefreshToken(@Param("refreshToken") String refreshToken); + + @Modifying + @Transactional + @Query("UPDATE User u SET u.petName = :petName, u.petWeight = :petWeight WHERE u.email = :email") + void updateUserByEmail(@Param("petName") String petName, @Param("petWeight") Integer petWeight, + @Param("email") String email); + + @Modifying + @Transactional + @Query("UPDATE User u SET u.petName = :petName, u.petWeight = :petWeight WHERE u.accessToken = :accessToken") + void updateUserByAccessToken(@Param("petName") String petName, @Param("petWeight") Integer petWeight, + @Param("accessToken") String accessToken); + + @Modifying + @Transactional + @Query("UPDATE User u SET u.accessToken = :accessToken WHERE u.refreshToken = :refreshToken") + void updateUserByRefreshToken(@Param("accessToken") String accessToken, @Param("refreshToken") String refreshToken); +} \ No newline at end of file diff --git a/docker-test-server/src/main/java/com/example/server/user/UserService.java b/docker-test-server/src/main/java/com/example/server/user/UserService.java new file mode 100644 index 0000000..6cde9de --- /dev/null +++ b/docker-test-server/src/main/java/com/example/server/user/UserService.java @@ -0,0 +1,11 @@ +package com.example.server.user; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserService { + +} diff --git a/docker-test-server/src/main/resources/application-dev.yml b/docker-test-server/src/main/resources/application-dev.yml new file mode 100644 index 0000000..80a0db0 --- /dev/null +++ b/docker-test-server/src/main/resources/application-dev.yml @@ -0,0 +1,36 @@ +server: + servlet: + encoding: + charset: utf-8 + force: true + port: 8080 + error: + whitelabel: + enabled: false +spring: + datasource: + url: jdbc:h2:mem:test;MODE=MySQL + driver-class-name: org.h2.Driver + username: sa + password: + h2: + console: + enabled: true + jpa: + open-in-view: false + hibernate: + ddl-auto: create + properties: + "[hibernate.default_batch_fetch_size]": 100 + "[hibernate.format_sql]": true + show-sql: true + output: + ansi: + enabled: always + key: + jwt-secret-key: ${JWT_SECRET_KEY} + kakao-api-key: ${KAKAO_API_CLIENT_KEY} +logging: + level: + "[com.example.server]": DEBUG + "[org.hibernate.type]": TRACE diff --git a/docker-test-server/src/main/resources/application-prod.yml b/docker-test-server/src/main/resources/application-prod.yml new file mode 100644 index 0000000..fb6498f --- /dev/null +++ b/docker-test-server/src/main/resources/application-prod.yml @@ -0,0 +1,29 @@ +server: + servlet: + encoding: + charset: utf-8 + force: true + port: 8080 + error: + whitelabel: + enabled: false +spring: + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: ${SPRING_DATASOURCE_DRIVER} + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: none + show-sql: true + open-in-view: true + key: + jwt-secret-key: ${JWT_SECRET_KEY} +logging: + level: + "[com.example.server]": INFO + "[org.hibernate.type]": TRACE diff --git a/docker-test-server/src/main/resources/application.yml b/docker-test-server/src/main/resources/application.yml new file mode 100644 index 0000000..be2a581 --- /dev/null +++ b/docker-test-server/src/main/resources/application.yml @@ -0,0 +1,30 @@ +server: + servlet: + encoding: + charset: utf-8 + force: true + port: 8080 + error: + whitelabel: + enabled: false +spring: + datasource: + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} + driver-class-name: ${SPRING_DATASOURCE_DRIVER} + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + hibernate: + ddl-auto: none + show-sql: true + open-in-view: true + key: + jwt-secret-key: ${JWT_SECRET_KEY} + kakao-api-key: ${KAKAO_API_CLIENT_KEY} +logging: + level: + "[com.example.server]": INFO + "[org.hibernate.type]": TRACE