├── .github └── workflows │ └── pr-test-bot.yml ├── .gitignore ├── Dockerfile ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── images └── cd496c4f9644c1e4f2872c1c6305eff2a4b3c0b8bfc7b2d2b97a683aca57178e.png ├── readme.md ├── settings.gradle.kts └── src ├── main ├── kotlin │ └── com │ │ └── template │ │ ├── Application.kt │ │ ├── auth │ │ ├── controller │ │ │ ├── AuthApiController.kt │ │ │ ├── request │ │ │ │ ├── AccessTokenUpdateRequest.kt │ │ │ │ └── LoginRequest.kt │ │ │ └── response │ │ │ │ ├── AccessTokenUpdateResponse.kt │ │ │ │ └── LoginResponse.kt │ │ ├── exception │ │ │ ├── AuthenticateException.kt │ │ │ ├── InvalidAccessException.kt │ │ │ ├── LoginException.kt │ │ │ ├── UserIdNotFoundException.kt │ │ │ └── UserUnAuthorizedException.kt │ │ ├── service │ │ │ ├── AuthCommandHandler.kt │ │ │ └── impl │ │ │ │ └── AuthCommandHandlerImpl.kt │ │ └── tools │ │ │ ├── JwtProperties.kt │ │ │ └── JwtTokenUtil.kt │ │ ├── common │ │ ├── exception │ │ │ ├── ApiException.kt │ │ │ ├── BadRequestException.kt │ │ │ ├── ConflictException.kt │ │ │ ├── ForbiddenException.kt │ │ │ ├── NotAcceptableException.kt │ │ │ ├── NotFoundException.kt │ │ │ └── UnauthorizedException.kt │ │ ├── function │ │ │ ├── AuthorizeUser.kt │ │ │ ├── CheckUserAccess.kt │ │ │ └── FindUser.kt │ │ └── response │ │ │ ├── BasicMessageResponse.kt │ │ │ └── ErrorResponse.kt │ │ ├── config │ │ ├── CORSConfig.kt │ │ ├── WebMvcConfig.kt │ │ ├── annotation │ │ │ └── LoggedInUser.kt │ │ └── handler │ │ │ ├── GlobalExceptionHandler.kt │ │ │ └── UserInfoArgumentResolver.kt │ │ ├── domain │ │ └── common │ │ │ ├── BaseTimeEntity.kt │ │ │ └── CreatedAtEntity.kt │ │ ├── security │ │ ├── config │ │ │ └── SecurityConfig.kt │ │ ├── filter │ │ │ └── JWTRequestFilter.kt │ │ ├── handler │ │ │ └── JWTAuthenticationEntryPoint.kt │ │ └── service │ │ │ ├── JWTUserDetailsService.kt │ │ │ └── UserDetailsImpl.kt │ │ └── user │ │ ├── controller │ │ ├── UserApiController.kt │ │ └── response │ │ │ └── UserInfoResponse.kt │ │ ├── domain │ │ ├── User.kt │ │ └── UserRepository.kt │ │ └── service │ │ ├── UserQueryHandler.kt │ │ ├── dto │ │ └── UserDto.kt │ │ └── impl │ │ └── UserQueryHandlerImpl.kt └── resources │ └── application.yml └── test ├── kotlin └── com │ └── template │ ├── integration │ ├── ActuatorTest.kt │ ├── ApiIntegrationTest.kt │ ├── auth │ │ ├── AccessTokenUpdateTest.kt │ │ └── LoginTest.kt │ └── user │ │ └── UserGetInfoTest.kt │ ├── unit │ ├── BaseUnitTest.kt │ ├── auth │ │ ├── AccessTokenUpdateServiceUnitTest.kt │ │ ├── JwtTokenUtilTest.kt │ │ └── LoginUnitTest.kt │ └── user │ │ └── UserGetInfoTest.kt │ └── util │ └── TestUtils.kt └── resources └── application-test.yml /.github/workflows/pr-test-bot.yml: -------------------------------------------------------------------------------- 1 | name: PullRequestGradleTest 2 | 3 | on: 4 | pull_request_target: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | test: 10 | name: GradleTest 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: checkout 15 | uses: actions/checkout@v2 16 | with: 17 | ref: ${{ github.event.pull_request.head.ref }} 18 | repository: ${{github.event.pull_request.head.repo.full_name }} 19 | 20 | - name: Setup JDK 1.8 21 | uses: actions/setup-java@v2 22 | with: 23 | java-version: '8' 24 | distribution: 'adopt' 25 | 26 | - name: Grant Permissions to gradlew 27 | run: chmod +x gradlew 28 | 29 | - name: Test 30 | run: ./gradlew test 31 | 32 | - name: Test Success 33 | if: success() 34 | uses: actions/github-script@0.2.0 35 | with: 36 | github-token: ${{ github.token }} 37 | script: | 38 | const pull_number = "${{github.event.number}}" 39 | await github.pulls.createReview({ 40 | ...context.repo, 41 | pull_number, 42 | body: "테스트 모두 통과!", 43 | event: "APPROVE" 44 | }) 45 | - name: Test Fail 46 | if: failure() 47 | uses: actions/github-script@0.2.0 48 | with: 49 | github-token: ${{ github.token }} 50 | script: | 51 | const pull_number = "${{github.event.number}}" 52 | await github.pulls.createReview({ 53 | ...context.repo, 54 | pull_number, 55 | body: "테스트 코드 실패.. 다시 작성해 주세요.", 56 | event: "REQUEST_CHANGES" 57 | }) 58 | 59 | - name: Check Lint 60 | run: ./gradlew ktlintCheck 61 | 62 | - name: Lint Success 63 | if: success() 64 | uses: actions/github-script@0.2.0 65 | with: 66 | github-token: ${{ github.token }} 67 | script: | 68 | const pull_number = "${{github.event.number}}" 69 | await github.pulls.createReview({ 70 | ...context.repo, 71 | pull_number, 72 | body: "이쁜 코드군요..!", 73 | event: "APPROVE" 74 | }) 75 | - name: Lint Fail 76 | if: failure() 77 | uses: actions/github-script@0.2.0 78 | with: 79 | github-token: ${{ github.token }} 80 | script: | 81 | const pull_number = "${{github.event.number}}" 82 | await github.pulls.createReview({ 83 | ...context.repo, 84 | pull_number, 85 | body: "lint 정도는 하고 올립시다~", 86 | event: "REQUEST_CHANGES" 87 | }) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.gradle/ 3 | /.idea/ 4 | build 5 | .env -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:8-jdk-alpine AS builder 2 | WORKDIR application 3 | COPY gradlew . 4 | COPY gradle gradle 5 | COPY build.gradle.kts . 6 | COPY settings.gradle.kts . 7 | COPY src src 8 | RUN chmod +x ./gradlew 9 | RUN ./gradlew bootJar 10 | ARG JAR_FILE=build/libs/*.jar 11 | COPY ${JAR_FILE} application.jar 12 | RUN java -Djarmode=layertools -jar application.jar extract 13 | 14 | FROM openjdk:8-jdk-alpine 15 | WORKDIR application 16 | COPY --from=builder application/dependencies/ ./ 17 | COPY --from=builder application/spring-boot-loader/ ./ 18 | COPY --from=builder application/snapshot-dependencies/ ./ 19 | COPY --from=builder application/application ./ 20 | ENV TZ Asia/Seoul 21 | ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"] -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | kotlin("jvm") version "1.5.0" 5 | kotlin("plugin.spring") version "1.3.72" 6 | kotlin("plugin.jpa") version "1.3.72" 7 | id("org.jetbrains.kotlin.plugin.noarg") version "1.4.31" 8 | id("org.springframework.boot") version "2.5.4" 9 | id("io.spring.dependency-management") version "1.0.11.RELEASE" 10 | id("org.jlleitschuh.gradle.ktlint") version "10.2.0" 11 | id("application") 12 | } 13 | 14 | group = "com.template" 15 | version = "1.0-SNAPSHOT" 16 | java.sourceCompatibility = JavaVersion.VERSION_1_8 17 | 18 | repositories { 19 | mavenCentral() 20 | } 21 | 22 | buildscript { 23 | repositories { 24 | maven(url = "https://plugins.gradle.org/m2/") 25 | } 26 | dependencies { 27 | classpath("org.jlleitschuh.gradle:ktlint-gradle:10.2.0") 28 | } 29 | } 30 | 31 | dependencies { 32 | implementation("org.jetbrains.kotlin:kotlin-stdlib:1.5.21") 33 | implementation("org.jetbrains.kotlin:kotlin-reflect") 34 | implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") 35 | implementation("org.jetbrains.kotlin:kotlin-noarg") 36 | implementation("org.springframework.boot:spring-boot-starter-web") 37 | implementation("org.springframework.boot:spring-boot-starter-validation") 38 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 39 | implementation("org.springframework.boot:spring-boot-starter-actuator") 40 | implementation("org.springframework.boot:spring-boot-starter-security") 41 | annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") 42 | implementation("mysql:mysql-connector-java") 43 | implementation("org.hibernate:hibernate-core:5.5.7.Final") 44 | implementation("io.jsonwebtoken:jjwt:0.9.1") 45 | implementation("io.springfox:springfox-boot-starter:3.0.0") 46 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin") 47 | testImplementation("org.springframework.boot:spring-boot-starter-test") 48 | testImplementation(kotlin("test-junit")) 49 | testImplementation("org.junit.jupiter:junit-jupiter-api:5.7.2") 50 | testImplementation("io.kotest:kotest-runner-junit5:5.0.0.M3") 51 | testImplementation("io.kotest:kotest-assertions-core:5.0.0.M3") 52 | testImplementation("com.ninja-squad:springmockk:3.0.1") 53 | testImplementation("com.h2database:h2") 54 | testImplementation("org.apache.httpcomponents:httpclient:4.5.13") 55 | } 56 | 57 | tasks.test { 58 | useJUnitPlatform() 59 | } 60 | 61 | configure { 62 | disabledRules.set(setOf("no-wildcard-imports")) 63 | } 64 | 65 | tasks.withType { 66 | kotlinOptions.jvmTarget = "1.8" 67 | } 68 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlin.code.style=official 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sang-w0o/spring-boot-mvc-kotlin-template/f87cc16600e6c23d62c4bb34ae915d1931850093/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | 86 | # Determine the Java command to use to start the JVM. 87 | if [ -n "$JAVA_HOME" ] ; then 88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 89 | # IBM's JDK on AIX uses strange locations for the executables 90 | JAVACMD="$JAVA_HOME/jre/sh/java" 91 | else 92 | JAVACMD="$JAVA_HOME/bin/java" 93 | fi 94 | if [ ! -x "$JAVACMD" ] ; then 95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 96 | 97 | Please set the JAVA_HOME variable in your environment to match the 98 | location of your Java installation." 99 | fi 100 | else 101 | JAVACMD="java" 102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 103 | 104 | Please set the JAVA_HOME variable in your environment to match the 105 | location of your Java installation." 106 | fi 107 | 108 | # Increase the maximum file descriptors if we can. 109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 110 | MAX_FD_LIMIT=`ulimit -H -n` 111 | if [ $? -eq 0 ] ; then 112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 113 | MAX_FD="$MAX_FD_LIMIT" 114 | fi 115 | ulimit -n $MAX_FD 116 | if [ $? -ne 0 ] ; then 117 | warn "Could not set maximum file descriptor limit: $MAX_FD" 118 | fi 119 | else 120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 121 | fi 122 | fi 123 | 124 | # For Darwin, add options to specify how the application appears in the dock 125 | if $darwin; then 126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 127 | fi 128 | 129 | # For Cygwin or MSYS, switch paths to Windows format before running java 130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 133 | 134 | JAVACMD=`cygpath --unix "$JAVACMD"` 135 | 136 | # We build the pattern for arguments to be converted via cygpath 137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 138 | SEP="" 139 | for dir in $ROOTDIRSRAW ; do 140 | ROOTDIRS="$ROOTDIRS$SEP$dir" 141 | SEP="|" 142 | done 143 | OURCYGPATTERN="(^($ROOTDIRS))" 144 | # Add a user-defined pattern to the cygpath arguments 145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 147 | fi 148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 149 | i=0 150 | for arg in "$@" ; do 151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 153 | 154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 156 | else 157 | eval `echo args$i`="\"$arg\"" 158 | fi 159 | i=`expr $i + 1` 160 | done 161 | case $i in 162 | 0) set -- ;; 163 | 1) set -- "$args0" ;; 164 | 2) set -- "$args0" "$args1" ;; 165 | 3) set -- "$args0" "$args1" "$args2" ;; 166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 172 | esac 173 | fi 174 | 175 | # Escape application args 176 | save () { 177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 178 | echo " " 179 | } 180 | APP_ARGS=`save "$@"` 181 | 182 | # Collect all arguments for the java command, following the shell quoting and substitution rules 183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 184 | 185 | exec "$JAVACMD" "$@" 186 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto execute 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto execute 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :execute 68 | @rem Setup the command line 69 | 70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 71 | 72 | 73 | @rem Execute Gradle 74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 75 | 76 | :end 77 | @rem End local scope for the variables with windows NT shell 78 | if "%ERRORLEVEL%"=="0" goto mainEnd 79 | 80 | :fail 81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 82 | rem the _cmd.exe /c_ return code! 83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 84 | exit /b 1 85 | 86 | :mainEnd 87 | if "%OS%"=="Windows_NT" endlocal 88 | 89 | :omega 90 | -------------------------------------------------------------------------------- /images/cd496c4f9644c1e4f2872c1c6305eff2a4b3c0b8bfc7b2d2b97a683aca57178e.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sang-w0o/spring-boot-mvc-kotlin-template/f87cc16600e6c23d62c4bb34ae915d1931850093/images/cd496c4f9644c1e4f2872c1c6305eff2a4b3c0b8bfc7b2d2b97a683aca57178e.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Spring Boot Template Repository 2 | 3 | - 기본적인 스프링 부트 관련 설정들이 되어 있는 템플릿 레포지토리 입니다. 4 | - Spring Boot, JPA, Spring Security에 관련된 의존성들이 추가되어 있습니다. 5 | - JWT 생성 및 검증을 위한 유틸리티 클래스가 있습니다. 6 | - `javax.validation.constraints` 하위의 필드 검증 어노테이션에 지정한 메시지가 7 | 요청 검증에 실패했을 때에 대한 Global Exception Handling이 구현되어 있습니다. 8 | (`@Valid`로 검증) 9 | - CORS 설정을 하여 모든 origin으로부터 모든 HTTP Method의 요청을 허용하도록 했습니다. 10 | - Swagger 기본 의존성이 추가되어 있습니다. 11 | (`http://localhost:8080/swagger-ui/index.html`) 12 | - Global Exception Handler를 적용하여 예외 발생 시에 대한 공통적인 응답 형식을 13 | `ErrorResponseDto`로 정의했습니다. 14 | 15 | - 응답 예시 16 | ```json 17 | { 18 | "timestamp": "2021-5-22 16:36", 19 | "status": 400, 20 | "error": "Bad Request", 21 | "message": "Name field is required.", 22 | "path": "/v1/user", 23 | "remote": "0:0:0:0:0:0:0:1" 24 | } 25 | ``` 26 | 27 | - JPA가 연결할 데이터베이스 종류가 MySQL, MariaDB로 설정되어 있습니다. 28 | - 데이터베이스와 JWT SecretKey와 관련된 변수들은 아래와 같이 설정되어 있습니다. 29 | 30 | ```properties 31 | spring.jpa.show-sql=false 32 | spring.jpa.database-platform=org.hibernate.dialect.MySQL5InnoDBDialect 33 | spring.jpa.open-in-view=false 34 | spring.jpa.hibernate.ddl-auto=none 35 | spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect 36 | spring.datasource.url=${DATASOURCE_URL} 37 | spring.datasource.username=${DATASOURCE_USERNAME} 38 | spring.datasource.password=${DATASOURCE_PASSWORD} 39 | spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver 40 | 41 | jwt.secret=${JWT_SECRET} 42 | ``` 43 | 44 | - 위 설정값에 값을 대입하여 사용하는 방법은 Intellij 기준으로 아래와 같습니다. 45 | 46 | 1. 상위의 `Application` => `Edit Configuration` 47 | 2. `Run/Debug Application` 창에서 `Environment` => `Environment Variables`의 오른쪽 아이콘 클릭 48 | 3. 위 yml 파일에 지정해준 이름을 Key값으로 value 각각 설정 49 | ![picture 1](images/cd496c4f9644c1e4f2872c1c6305eff2a4b3c0b8bfc7b2d2b97a683aca57178e.png) 50 | 51 | - 기본적으로 AccessToken은 1일, RefreshToken은 7일의 수명을 가집니다. 52 | AccessToken을 갱신하는 API는 아래와 같습니다. 53 | 54 | - [POST] `/v1/auth/update-token` 55 | - Authorization Header: 불필요 56 | - Request Body 57 | ```json 58 | { 59 | "refreshToken": "string" 60 | } 61 | ``` 62 | - Response Body(정상 처리 시) 63 | ```json 64 | { 65 | "accessToken": "string" 66 | } 67 | ``` 68 | - 예외상황(ex. refreshToken 수명 만료)들에 대해서는 위에서 정의한 69 | `ErrorResponseDto`의 형식으로 응답이 옵니다. -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | rootProject.name = "spring-boot-template" 3 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/Application.kt: -------------------------------------------------------------------------------- 1 | package com.template 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.data.jpa.repository.config.EnableJpaAuditing 7 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 8 | 9 | @SpringBootApplication 10 | @EnableJpaAuditing 11 | class Application { 12 | @Bean 13 | fun passwordEncoder(): BCryptPasswordEncoder { 14 | return BCryptPasswordEncoder(10) 15 | } 16 | } 17 | fun main(args: Array) { 18 | runApplication(*args) 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/controller/AuthApiController.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.controller 2 | 3 | import com.template.auth.controller.request.AccessTokenUpdateRequest 4 | import com.template.auth.controller.request.LoginRequest 5 | import com.template.auth.controller.response.AccessTokenUpdateResponse 6 | import com.template.auth.controller.response.LoginResponse 7 | import com.template.auth.service.AuthCommandHandler 8 | import org.springframework.web.bind.annotation.PostMapping 9 | import org.springframework.web.bind.annotation.RequestBody 10 | import org.springframework.web.bind.annotation.RestController 11 | import javax.validation.Valid 12 | 13 | @RestController 14 | class AuthApiController( 15 | private val authCommandHandler: AuthCommandHandler 16 | ) { 17 | 18 | @PostMapping("/v1/auth/update-token") 19 | fun updateAccessToken(@Valid @RequestBody request: AccessTokenUpdateRequest): AccessTokenUpdateResponse { 20 | return AccessTokenUpdateResponse(authCommandHandler.updateAccessToken(request.refreshToken)) 21 | } 22 | 23 | @PostMapping("/v1/auth/login") 24 | fun login(@Valid @RequestBody request: LoginRequest): LoginResponse { 25 | val (email, password) = request 26 | val result = authCommandHandler.login(email, password) 27 | return LoginResponse(result.first, result.second) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/controller/request/AccessTokenUpdateRequest.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.controller.request 2 | 3 | import javax.validation.constraints.NotBlank 4 | 5 | data class AccessTokenUpdateRequest( 6 | @field:NotBlank(message = "refreshToken is required.") 7 | val refreshToken: String = "", 8 | ) 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/controller/request/LoginRequest.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.controller.request 2 | 3 | import javax.validation.constraints.Email 4 | import javax.validation.constraints.NotBlank 5 | import javax.validation.constraints.Size 6 | 7 | data class LoginRequest( 8 | @field:Email 9 | @field:NotBlank(message = "Email is required.") 10 | var email: String = "", 11 | 12 | @field:NotBlank(message = "Password is required.") 13 | @field:Size(min = 5, max = 72) 14 | var password: String = "" 15 | ) 16 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/controller/response/AccessTokenUpdateResponse.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.controller.response 2 | 3 | data class AccessTokenUpdateResponse( 4 | val accessToken: String = "" 5 | ) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/controller/response/LoginResponse.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.controller.response 2 | 3 | data class LoginResponse( 4 | val accessToken: String = "", 5 | val refreshToken: String = "" 6 | ) 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/exception/AuthenticateException.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.exception 2 | 3 | import org.springframework.security.core.AuthenticationException 4 | 5 | class AuthenticateException(message: String) : AuthenticationException(message) 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/exception/InvalidAccessException.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.exception 2 | 3 | import com.template.common.exception.ForbiddenException 4 | 5 | class InvalidAccessException : ForbiddenException { 6 | constructor(message: String) : super(message) 7 | constructor() : super("해당 리소스에 대한 권한이 없습니다.") 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/exception/LoginException.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.exception 2 | 3 | import com.template.common.exception.NotFoundException 4 | 5 | class LoginException : NotFoundException { 6 | constructor() : super("이메일 또는 비밀번호가 잘못되었습니다.") 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/exception/UserIdNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.exception 2 | 3 | import com.template.common.exception.NotFoundException 4 | 5 | class UserIdNotFoundException : NotFoundException { 6 | constructor(message: String) : super(message) 7 | constructor() : super("userId is invalid.") 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/exception/UserUnAuthorizedException.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.exception 2 | 3 | import com.template.common.exception.UnauthorizedException 4 | 5 | class UserUnAuthorizedException : UnauthorizedException { 6 | constructor(message: String) : super(message) 7 | constructor() : super("인증되지 않은 사용자 입니다.") 8 | } 9 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/service/AuthCommandHandler.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.service 2 | 3 | interface AuthCommandHandler { 4 | fun updateAccessToken(refreshToken: String): String 5 | fun login(email: String, password: String): Pair 6 | } 7 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/service/impl/AuthCommandHandlerImpl.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.service.impl 2 | 3 | import com.template.auth.exception.AuthenticateException 4 | import com.template.auth.exception.LoginException 5 | import com.template.auth.service.AuthCommandHandler 6 | import com.template.auth.tools.JwtTokenUtil 7 | import com.template.user.domain.UserRepository 8 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 9 | import org.springframework.stereotype.Service 10 | import org.springframework.transaction.annotation.Transactional 11 | 12 | @Service 13 | class AuthCommandHandlerImpl( 14 | private val jwtTokenUtil: JwtTokenUtil, 15 | private val userRepository: UserRepository, 16 | private val encoder: BCryptPasswordEncoder 17 | ) : AuthCommandHandler { 18 | @Transactional(readOnly = true) 19 | override fun updateAccessToken(refreshToken: String): String { 20 | if (!jwtTokenUtil.isTokenExpired(refreshToken)) { 21 | val userId = jwtTokenUtil.extractUserId(refreshToken) 22 | if (userRepository.existsById(userId)) { 23 | return jwtTokenUtil.generateAccessToken(userId) 24 | } else throw AuthenticateException("Unauthorized User Id.") 25 | } else throw AuthenticateException("RefreshToken has been expired.") 26 | } 27 | 28 | @Transactional(readOnly = true) 29 | override fun login(email: String, password: String): Pair { 30 | val user = userRepository.findByEmail(email).orElseThrow { LoginException() } 31 | if (!encoder.matches(password, user.password)) throw LoginException() 32 | val accessToken = jwtTokenUtil.generateAccessToken(user.id!!) 33 | val refreshToken = jwtTokenUtil.generateAccessToken(user.id!!) 34 | return Pair(accessToken, refreshToken) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/tools/JwtProperties.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.tools 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties 4 | import org.springframework.stereotype.Component 5 | 6 | @Component 7 | @ConfigurationProperties(prefix = "jwt") 8 | class JwtProperties { 9 | var secret: String = "" 10 | var accessTokenExp: Int = 0 11 | var refreshTokenExp: Int = 0 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/auth/tools/JwtTokenUtil.kt: -------------------------------------------------------------------------------- 1 | package com.template.auth.tools 2 | 3 | import com.template.auth.exception.AuthenticateException 4 | import com.template.user.domain.UserRepository 5 | import io.jsonwebtoken.Claims 6 | import io.jsonwebtoken.ExpiredJwtException 7 | import io.jsonwebtoken.Jwts 8 | import io.jsonwebtoken.MalformedJwtException 9 | import io.jsonwebtoken.SignatureAlgorithm 10 | import io.jsonwebtoken.SignatureException 11 | import io.jsonwebtoken.UnsupportedJwtException 12 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken 13 | import org.springframework.security.core.Authentication 14 | import org.springframework.stereotype.Component 15 | import java.lang.IllegalArgumentException 16 | import java.util.* 17 | import java.util.function.Function 18 | 19 | @Component 20 | class JwtTokenUtil( 21 | private val userRepository: UserRepository, 22 | private val jwtProperties: JwtProperties 23 | ) { 24 | 25 | private fun getUserId(claim: Claims): Int { 26 | try { 27 | return claim.get("userId", Int::class.javaObjectType) 28 | } catch (e: Exception) { 29 | throw AuthenticateException("JWT Claim에 userId가 없습니다.") 30 | } 31 | } 32 | 33 | private fun extractExp(token: String): Date { 34 | return extractClaim(token, Claims::getExpiration) 35 | } 36 | 37 | private fun extractAllClaims(token: String): Claims { 38 | try { 39 | return Jwts.parser().setSigningKey(jwtProperties.secret.toByteArray()).parseClaimsJws(token).body 40 | } catch (expiredJwtException: ExpiredJwtException) { 41 | throw AuthenticateException("Jwt 토큰이 만료되었습니다.") 42 | } catch (unsupportedJwtException: UnsupportedJwtException) { 43 | throw AuthenticateException("지원되지 않는 Jwt 토큰입니다.") 44 | } catch (malformedJwtException: MalformedJwtException) { 45 | throw AuthenticateException("잘못된 형식의 Jwt 토큰입니다.") 46 | } catch (signatureException: SignatureException) { 47 | throw AuthenticateException("Jwt Signature이 잘못된 값입니다.") 48 | } catch (illegalArgumentException: IllegalArgumentException) { 49 | throw AuthenticateException("Jwt 헤더 값이 잘못되었습니다.") 50 | } 51 | } 52 | 53 | private fun createToken(claims: Map, exp: Int): String { 54 | return Jwts.builder() 55 | .setClaims(claims) 56 | .setIssuedAt(Date(System.currentTimeMillis())) 57 | .setExpiration(Date(System.currentTimeMillis() + exp)) 58 | .signWith(SignatureAlgorithm.HS256, jwtProperties.secret.toByteArray()) 59 | .compact() 60 | } 61 | 62 | fun extractClaim(token: String, claimResolver: Function): T { 63 | return claimResolver.apply(extractAllClaims(token)) 64 | } 65 | 66 | fun extractUserId(token: String): Int { 67 | return extractClaim(token, this::getUserId) 68 | } 69 | 70 | fun isTokenExpired(token: String): Boolean { 71 | return extractExp(token).before(Date()) 72 | } 73 | 74 | fun verify(token: String): Authentication { 75 | extractAllClaims(token) 76 | val user = userRepository.findById(extractUserId(token)).orElseThrow { AuthenticateException("Invalid userId.") } 77 | return UsernamePasswordAuthenticationToken(user.toUserDto(), "", mutableListOf()) 78 | } 79 | 80 | fun generateAccessToken(userId: Int): String { 81 | val claims: MutableMap = mutableMapOf() 82 | claims["userId"] = userId 83 | return createToken(claims, jwtProperties.accessTokenExp) 84 | } 85 | 86 | fun generateRefreshToken(userId: Int): String { 87 | val claims: MutableMap = mutableMapOf() 88 | claims["userId"] = userId 89 | return createToken(claims, jwtProperties.refreshTokenExp) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/common/exception/ApiException.kt: -------------------------------------------------------------------------------- 1 | package com.template.common.exception 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.server.ResponseStatusException 5 | 6 | abstract class ApiException(message: String, status: HttpStatus) : ResponseStatusException(status, message) { 7 | override val message: String 8 | get() = reason ?: "No message provided." 9 | } 10 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/common/exception/BadRequestException.kt: -------------------------------------------------------------------------------- 1 | package com.template.common.exception 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | @ResponseStatus(HttpStatus.BAD_REQUEST) 7 | abstract class BadRequestException(message: String) : ApiException(message, HttpStatus.BAD_REQUEST) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/common/exception/ConflictException.kt: -------------------------------------------------------------------------------- 1 | package com.template.common.exception 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | @ResponseStatus(HttpStatus.CONFLICT) 7 | abstract class ConflictException(message: String) : ApiException(message, HttpStatus.CONFLICT) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/common/exception/ForbiddenException.kt: -------------------------------------------------------------------------------- 1 | package com.template.common.exception 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | @ResponseStatus(HttpStatus.FORBIDDEN) 7 | abstract class ForbiddenException(message: String) : ApiException(message, HttpStatus.FORBIDDEN) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/common/exception/NotAcceptableException.kt: -------------------------------------------------------------------------------- 1 | package com.template.common.exception 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | @ResponseStatus(HttpStatus.NOT_ACCEPTABLE) 7 | abstract class NotAcceptableException(message: String) : ApiException(message, HttpStatus.NOT_ACCEPTABLE) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/common/exception/NotFoundException.kt: -------------------------------------------------------------------------------- 1 | package com.template.common.exception 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | @ResponseStatus(HttpStatus.NOT_FOUND) 7 | abstract class NotFoundException(message: String) : ApiException(message, HttpStatus.NOT_FOUND) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/common/exception/UnauthorizedException.kt: -------------------------------------------------------------------------------- 1 | package com.template.common.exception 2 | 3 | import org.springframework.http.HttpStatus 4 | import org.springframework.web.bind.annotation.ResponseStatus 5 | 6 | @ResponseStatus(HttpStatus.UNAUTHORIZED) 7 | abstract class UnauthorizedException(message: String) : ApiException(message, HttpStatus.UNAUTHORIZED) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/common/function/AuthorizeUser.kt: -------------------------------------------------------------------------------- 1 | package com.template.common.function 2 | 3 | import com.template.user.domain.User 4 | import com.template.user.domain.UserRepository 5 | import org.springframework.beans.factory.annotation.Autowired 6 | import org.springframework.security.core.userdetails.UsernameNotFoundException 7 | import org.springframework.stereotype.Component 8 | import java.util.function.Function 9 | 10 | @Component 11 | class AuthorizeUser : Function { 12 | 13 | @Autowired 14 | private lateinit var userRepository: UserRepository 15 | 16 | override fun apply(userId: Int): User { 17 | return userRepository.findById(userId).orElseThrow { UsernameNotFoundException("잘못된 userId 값입니다.") } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/common/function/CheckUserAccess.kt: -------------------------------------------------------------------------------- 1 | package com.template.common.function 2 | 3 | import com.template.auth.exception.InvalidAccessException 4 | import org.springframework.stereotype.Component 5 | import java.util.function.Consumer 6 | 7 | @Component 8 | class CheckUserAccess(private val findUser: FindUser) : Consumer { 9 | override fun accept(userId: Int) { 10 | val user = findUser.get() 11 | if (user.id!! != userId) throw InvalidAccessException() 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/common/function/FindUser.kt: -------------------------------------------------------------------------------- 1 | package com.template.common.function 2 | 3 | import com.template.auth.exception.UserUnAuthorizedException 4 | import com.template.security.service.UserDetailsImpl 5 | import com.template.user.domain.User 6 | import com.template.user.domain.UserRepository 7 | import org.springframework.beans.factory.annotation.Autowired 8 | import org.springframework.security.core.context.SecurityContextHolder 9 | import org.springframework.stereotype.Component 10 | import java.util.function.Supplier 11 | 12 | @Component 13 | class FindUser : Supplier { 14 | 15 | @Autowired 16 | private lateinit var userRepository: UserRepository 17 | 18 | override fun get(): User { 19 | val userId = Integer.parseInt((SecurityContextHolder.getContext().authentication.principal as UserDetailsImpl).username) 20 | return userRepository.findById(userId).orElseThrow { UserUnAuthorizedException() } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/common/response/BasicMessageResponse.kt: -------------------------------------------------------------------------------- 1 | package com.template.common.response 2 | 3 | data class BasicMessageResponse(val message: String) 4 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/common/response/ErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package com.template.common.response 2 | 3 | import com.fasterxml.jackson.annotation.JsonFormat 4 | import java.time.LocalDateTime 5 | 6 | data class ErrorResponse( 7 | @field:JsonFormat( 8 | shape = JsonFormat.Shape.STRING, 9 | pattern = "yyyy-MM-dd HH:mm:ss", 10 | locale = "Asia/Seoul" 11 | ) 12 | val timestamp: LocalDateTime, 13 | val status: Int, 14 | val error: String, 15 | val message: String, 16 | val path: String, 17 | val remote: String? 18 | ) 19 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/config/CORSConfig.kt: -------------------------------------------------------------------------------- 1 | package com.template.config 2 | 3 | import org.springframework.context.annotation.Configuration 4 | import org.springframework.web.servlet.config.annotation.CorsRegistry 5 | import org.springframework.web.servlet.config.annotation.EnableWebMvc 6 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 7 | 8 | @Configuration 9 | @EnableWebMvc 10 | class CORSConfig : WebMvcConfigurer { 11 | override fun addCorsMappings(registry: CorsRegistry) { 12 | registry.addMapping("/**").allowedOrigins("*").allowedMethods("*") 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/config/WebMvcConfig.kt: -------------------------------------------------------------------------------- 1 | package com.template.config 2 | 3 | import com.template.config.handler.UserInfoArgumentResolver 4 | import org.springframework.context.annotation.Configuration 5 | import org.springframework.web.method.support.HandlerMethodArgumentResolver 6 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer 7 | 8 | @Configuration 9 | class WebMvcConfig( 10 | private val userInfoArgumentResolver: UserInfoArgumentResolver 11 | ) : WebMvcConfigurer { 12 | 13 | override fun addArgumentResolvers(resolvers: MutableList) { 14 | resolvers.add(userInfoArgumentResolver) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/config/annotation/LoggedInUser.kt: -------------------------------------------------------------------------------- 1 | package com.template.config.annotation 2 | 3 | @Target(AnnotationTarget.VALUE_PARAMETER) 4 | @Retention(AnnotationRetention.RUNTIME) 5 | annotation class LoggedInUser 6 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/config/handler/GlobalExceptionHandler.kt: -------------------------------------------------------------------------------- 1 | package com.template.config.handler 2 | 3 | import com.template.auth.exception.AuthenticateException 4 | import com.template.common.exception.ApiException 5 | import com.template.common.response.ErrorResponse 6 | import org.springframework.http.HttpHeaders 7 | import org.springframework.http.HttpStatus 8 | import org.springframework.http.ResponseEntity 9 | import org.springframework.web.bind.MethodArgumentNotValidException 10 | import org.springframework.web.bind.annotation.ExceptionHandler 11 | import org.springframework.web.bind.annotation.RestControllerAdvice 12 | import org.springframework.web.context.request.ServletWebRequest 13 | import org.springframework.web.context.request.WebRequest 14 | import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler 15 | import java.lang.Exception 16 | import java.time.LocalDateTime 17 | 18 | @RestControllerAdvice 19 | class GlobalExceptionHandler : ResponseEntityExceptionHandler() { 20 | 21 | @ExceptionHandler(value = [Exception::class]) 22 | protected fun handleApiException(exception: Exception, request: WebRequest): ResponseEntity { 23 | return when (exception) { 24 | is ApiException -> { 25 | handleExceptionInternal(exception, null, HttpHeaders(), exception.status, request) 26 | } 27 | is AuthenticateException -> { 28 | handleExceptionInternal(exception, null, HttpHeaders(), HttpStatus.UNAUTHORIZED, request) 29 | } 30 | else -> handleExceptionInternal(exception, null, HttpHeaders(), HttpStatus.INTERNAL_SERVER_ERROR, request) 31 | } 32 | } 33 | 34 | override fun handleMethodArgumentNotValid( 35 | ex: MethodArgumentNotValidException, 36 | headers: HttpHeaders, 37 | status: HttpStatus, 38 | request: WebRequest 39 | ): ResponseEntity { 40 | val servletWebRequest = request as ServletWebRequest 41 | val errorResponse = ErrorResponse(LocalDateTime.now(), status.value(), status.reasonPhrase, ex.bindingResult.fieldErrors[0].defaultMessage!!, servletWebRequest.request.requestURI, servletWebRequest.request.remoteAddr) 42 | return ResponseEntity(errorResponse, headers, status) 43 | } 44 | 45 | override fun handleExceptionInternal( 46 | ex: Exception, 47 | body: Any?, 48 | headers: HttpHeaders, 49 | status: HttpStatus, 50 | request: WebRequest 51 | ): ResponseEntity { 52 | val errorResponse: ErrorResponse 53 | val servletWebRequest = request as ServletWebRequest 54 | errorResponse = if (status == HttpStatus.INTERNAL_SERVER_ERROR) { 55 | ErrorResponse(LocalDateTime.now(), status.value(), status.reasonPhrase, "Internal Server Error", servletWebRequest.request.requestURI, servletWebRequest.request.remoteAddr) 56 | } else { 57 | ErrorResponse(LocalDateTime.now(), status.value(), status.reasonPhrase, ex.message!!, servletWebRequest.request.requestURI, servletWebRequest.request.remoteAddr) 58 | } 59 | return ResponseEntity(errorResponse, headers, status) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/config/handler/UserInfoArgumentResolver.kt: -------------------------------------------------------------------------------- 1 | package com.template.config.handler 2 | 3 | import com.template.config.annotation.LoggedInUser 4 | import com.template.user.service.dto.UserDto 5 | import org.springframework.core.MethodParameter 6 | import org.springframework.security.core.context.SecurityContextHolder 7 | import org.springframework.stereotype.Component 8 | import org.springframework.web.bind.support.WebDataBinderFactory 9 | import org.springframework.web.context.request.NativeWebRequest 10 | import org.springframework.web.method.support.HandlerMethodArgumentResolver 11 | import org.springframework.web.method.support.ModelAndViewContainer 12 | 13 | @Component 14 | class UserInfoArgumentResolver : HandlerMethodArgumentResolver { 15 | override fun supportsParameter(parameter: MethodParameter): Boolean { 16 | return parameter.getParameterAnnotation(LoggedInUser::class.java) != null && parameter.parameterType == UserDto::class.java 17 | } 18 | 19 | override fun resolveArgument( 20 | parameter: MethodParameter, 21 | mavContainer: ModelAndViewContainer?, 22 | webRequest: NativeWebRequest, 23 | binderFactory: WebDataBinderFactory? 24 | ): Any? { 25 | val securityContext = SecurityContextHolder.getContext() 26 | val authentication = securityContext.authentication 27 | if (authentication.principal is UserDto) { 28 | return authentication.principal as UserDto 29 | } else throw AssertionError("Authentication.principal is not type of UserDto") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/domain/common/BaseTimeEntity.kt: -------------------------------------------------------------------------------- 1 | package com.template.domain.common 2 | 3 | import org.springframework.data.annotation.CreatedDate 4 | import org.springframework.data.annotation.LastModifiedDate 5 | import org.springframework.data.jpa.domain.support.AuditingEntityListener 6 | import java.time.LocalDateTime 7 | import javax.persistence.EntityListeners 8 | import javax.persistence.MappedSuperclass 9 | 10 | @MappedSuperclass 11 | @EntityListeners(AuditingEntityListener::class) 12 | abstract class BaseTimeEntity { 13 | @CreatedDate 14 | var createdAt: LocalDateTime? = null 15 | 16 | @LastModifiedDate 17 | var updatedAt: LocalDateTime? = null 18 | } 19 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/domain/common/CreatedAtEntity.kt: -------------------------------------------------------------------------------- 1 | package com.template.domain.common 2 | 3 | import org.springframework.data.annotation.CreatedDate 4 | import org.springframework.data.jpa.domain.support.AuditingEntityListener 5 | import java.time.LocalDateTime 6 | import javax.persistence.EntityListeners 7 | import javax.persistence.MappedSuperclass 8 | 9 | @MappedSuperclass 10 | @EntityListeners(AuditingEntityListener::class) 11 | abstract class CreatedAtEntity { 12 | @CreatedDate 13 | var createdAt: LocalDateTime? = null 14 | } 15 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/security/config/SecurityConfig.kt: -------------------------------------------------------------------------------- 1 | package com.template.security.config 2 | 3 | import com.template.auth.tools.JwtTokenUtil 4 | import com.template.security.filter.JWTRequestFilter 5 | import org.springframework.context.annotation.Bean 6 | import org.springframework.http.HttpMethod 7 | import org.springframework.security.config.annotation.web.builders.HttpSecurity 8 | import org.springframework.security.config.annotation.web.builders.WebSecurity 9 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity 10 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter 11 | import org.springframework.security.config.http.SessionCreationPolicy 12 | import org.springframework.security.config.web.servlet.invoke 13 | import org.springframework.security.web.AuthenticationEntryPoint 14 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter 15 | import org.springframework.security.web.firewall.HttpStatusRequestRejectedHandler 16 | import org.springframework.security.web.firewall.RequestRejectedHandler 17 | import org.springframework.web.cors.CorsConfiguration 18 | import org.springframework.web.cors.CorsConfigurationSource 19 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource 20 | 21 | @EnableWebSecurity 22 | class SecurityConfig(private val jwtTokenUtil: JwtTokenUtil, private val authenticationEntryPoint: AuthenticationEntryPoint) : WebSecurityConfigurerAdapter() { 23 | 24 | override fun configure(http: HttpSecurity) { 25 | // TODO: JWT 인증 절차를 추가할 End point 추가 및 수정 26 | http { 27 | httpBasic { 28 | disable() 29 | } 30 | headers { 31 | frameOptions { disable() } 32 | } 33 | csrf { disable() } 34 | cors { configurationSource = corsConfigurationSource() } 35 | logout { disable() } 36 | sessionManagement { 37 | sessionCreationPolicy = SessionCreationPolicy.STATELESS 38 | } 39 | authorizeRequests { 40 | authorize("/v1/**", authenticated) 41 | } 42 | addFilterBefore(JWTRequestFilter(jwtTokenUtil, authenticationEntryPoint), UsernamePasswordAuthenticationFilter::class.java) 43 | exceptionHandling { 44 | authenticationEntryPoint 45 | } 46 | } 47 | } 48 | 49 | override fun configure(web: WebSecurity) { 50 | // TODO: JWT 인증 절차를 제외할 End point 추가 및 수정 51 | web.ignoring() 52 | .mvcMatchers(HttpMethod.POST, "/v1/auth/update-token") 53 | .mvcMatchers(HttpMethod.POST, "/v1/auth/login") 54 | .mvcMatchers(HttpMethod.GET, "/swagger-ui/**") 55 | .mvcMatchers(HttpMethod.GET, "/actuator/**") 56 | } 57 | 58 | @Bean 59 | fun corsConfigurationSource(): CorsConfigurationSource { 60 | val configuration = CorsConfiguration() 61 | configuration.addAllowedOriginPattern("*") 62 | configuration.addAllowedHeader("*") 63 | configuration.addAllowedMethod("*") 64 | configuration.allowCredentials = true 65 | 66 | val source = UrlBasedCorsConfigurationSource() 67 | source.registerCorsConfiguration("/**", configuration) 68 | return source 69 | } 70 | 71 | @Bean 72 | fun httpStatusRequestRejectedHandler(): RequestRejectedHandler { 73 | return HttpStatusRequestRejectedHandler() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/security/filter/JWTRequestFilter.kt: -------------------------------------------------------------------------------- 1 | package com.template.security.filter 2 | 3 | import com.template.auth.exception.AuthenticateException 4 | import com.template.auth.tools.JwtTokenUtil 5 | import org.springframework.security.core.context.SecurityContextHolder 6 | import org.springframework.security.web.AuthenticationEntryPoint 7 | import org.springframework.web.filter.OncePerRequestFilter 8 | import javax.servlet.FilterChain 9 | import javax.servlet.http.HttpServletRequest 10 | import javax.servlet.http.HttpServletResponse 11 | 12 | class JWTRequestFilter( 13 | private val jwtTokenUtil: JwtTokenUtil, 14 | private val authenticationEntryPoint: AuthenticationEntryPoint 15 | ) : OncePerRequestFilter() { 16 | 17 | companion object { 18 | private const val BEARER_SCHEME = "Bearer" 19 | private const val AUTHORIZATION_HEADER = "Authorization" 20 | } 21 | 22 | override fun doFilterInternal( 23 | request: HttpServletRequest, 24 | response: HttpServletResponse, 25 | filterChain: FilterChain 26 | ) { 27 | try { 28 | val authorizationHeader = request.getHeader(AUTHORIZATION_HEADER) 29 | ?: throw AuthenticateException("Authorization Header is missing.") 30 | val token = extractAccessToken(authorizationHeader) 31 | val authentication = jwtTokenUtil.verify(token) 32 | val context = SecurityContextHolder.getContext() 33 | context.authentication = authentication 34 | filterChain.doFilter(request, response) 35 | } catch (exception: AuthenticateException) { 36 | authenticationEntryPoint.commence(request, response, exception) 37 | } 38 | } 39 | 40 | private fun validateAuthorizationHeader(splits: List) { 41 | if (splits.size != 2) throw AuthenticateException("Authorization Header is malformed.") 42 | val scheme = splits[0] 43 | if (scheme != BEARER_SCHEME) throw AuthenticateException("Scheme is not Bearer.") 44 | } 45 | 46 | private fun extractAccessToken(authorizationHeader: String): String { 47 | val splits = authorizationHeader.split(" ") 48 | validateAuthorizationHeader(splits) 49 | return splits[1] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/security/handler/JWTAuthenticationEntryPoint.kt: -------------------------------------------------------------------------------- 1 | package com.template.security.handler 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule 5 | import com.fasterxml.jackson.module.kotlin.KotlinModule 6 | import com.template.common.response.ErrorResponse 7 | import org.springframework.http.HttpStatus 8 | import org.springframework.http.MediaType 9 | import org.springframework.security.core.AuthenticationException 10 | import org.springframework.security.web.AuthenticationEntryPoint 11 | import org.springframework.stereotype.Component 12 | import java.time.LocalDateTime 13 | import javax.servlet.http.HttpServletRequest 14 | import javax.servlet.http.HttpServletResponse 15 | 16 | @Component 17 | class JWTAuthenticationEntryPoint : AuthenticationEntryPoint { 18 | override fun commence( 19 | request: HttpServletRequest?, 20 | response: HttpServletResponse?, 21 | exception: AuthenticationException? 22 | ) { 23 | request!! 24 | val errorResponse = ErrorResponse( 25 | LocalDateTime.now(), HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.reasonPhrase, exception?.message!!, request.requestURI, request.remoteAddr 26 | ) 27 | response?.status = HttpStatus.UNAUTHORIZED.value() 28 | response?.contentType = MediaType.APPLICATION_JSON_VALUE 29 | response?.characterEncoding = "UTF-8" 30 | response?.writer?.println(convertObjectToJson(errorResponse)) 31 | } 32 | 33 | private fun convertObjectToJson(obj: Any): String? { 34 | val mapper = ObjectMapper().registerModule(KotlinModule()).registerModule(JavaTimeModule()) 35 | return mapper.writeValueAsString(obj) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/security/service/JWTUserDetailsService.kt: -------------------------------------------------------------------------------- 1 | package com.template.security.service 2 | 3 | import com.template.common.function.AuthorizeUser 4 | import org.springframework.security.core.GrantedAuthority 5 | import org.springframework.security.core.userdetails.UserDetails 6 | import org.springframework.security.core.userdetails.UserDetailsService 7 | import org.springframework.stereotype.Service 8 | 9 | @Service 10 | class JWTUserDetailsService(private val authorizeUser: AuthorizeUser) : UserDetailsService { 11 | override fun loadUserByUsername(username: String?): UserDetails { 12 | val user = authorizeUser.apply(Integer.parseInt(username!!)) 13 | return UserDetailsImpl(user.id!!) 14 | } 15 | 16 | fun getAuthorities(): Set { 17 | return mutableSetOf() 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/security/service/UserDetailsImpl.kt: -------------------------------------------------------------------------------- 1 | package com.template.security.service 2 | 3 | import org.springframework.security.core.GrantedAuthority 4 | import org.springframework.security.core.userdetails.UserDetails 5 | 6 | class UserDetailsImpl( 7 | private val id: Int 8 | ) : UserDetails { 9 | 10 | override fun isEnabled(): Boolean { 11 | return true 12 | } 13 | 14 | override fun getPassword(): String { 15 | return "" 16 | } 17 | 18 | override fun getUsername(): String { 19 | return id.toString() 20 | } 21 | 22 | override fun isAccountNonExpired(): Boolean { 23 | return true 24 | } 25 | 26 | override fun isAccountNonLocked(): Boolean { 27 | return true 28 | } 29 | 30 | override fun isCredentialsNonExpired(): Boolean { 31 | return true 32 | } 33 | 34 | override fun getAuthorities(): MutableCollection { 35 | return mutableSetOf() 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/user/controller/UserApiController.kt: -------------------------------------------------------------------------------- 1 | package com.template.user.controller 2 | 3 | import com.template.config.annotation.LoggedInUser 4 | import com.template.user.controller.response.UserInfoResponse 5 | import com.template.user.service.UserQueryHandler 6 | import com.template.user.service.dto.UserDto 7 | import org.springframework.web.bind.annotation.GetMapping 8 | import org.springframework.web.bind.annotation.RequestMapping 9 | import org.springframework.web.bind.annotation.RestController 10 | 11 | @RestController 12 | @RequestMapping("/v1/user/") 13 | class UserApiController( 14 | private val userQueryHandler: UserQueryHandler 15 | ) { 16 | 17 | @GetMapping 18 | fun getUserInfo(@LoggedInUser user: UserDto) = UserInfoResponse.from(userQueryHandler.getUserInfo(user)) 19 | } 20 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/user/controller/response/UserInfoResponse.kt: -------------------------------------------------------------------------------- 1 | package com.template.user.controller.response 2 | 3 | import com.template.user.service.dto.UserDto 4 | 5 | data class UserInfoResponse( 6 | val id: Int, 7 | val name: String, 8 | val email: String 9 | ) { 10 | companion object { 11 | fun from(userDto: UserDto) = UserInfoResponse(userDto.id, userDto.name, userDto.email) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/user/domain/User.kt: -------------------------------------------------------------------------------- 1 | package com.template.user.domain 2 | 3 | import com.template.domain.common.CreatedAtEntity 4 | import com.template.user.service.dto.UserDto 5 | import javax.persistence.Column 6 | import javax.persistence.Entity 7 | import javax.persistence.GeneratedValue 8 | import javax.persistence.GenerationType 9 | import javax.persistence.Id 10 | import javax.persistence.Table 11 | 12 | @Entity 13 | @Table(name = "users") 14 | class User(name: String, email: String, password: String) : CreatedAtEntity() { 15 | @Id 16 | @GeneratedValue(strategy = GenerationType.IDENTITY) 17 | @Column(nullable = false, name = "user_id") 18 | var id: Int? = null 19 | 20 | @Column(nullable = false, length = 100) 21 | var name: String = name 22 | 23 | @Column(length = 60) 24 | var password: String? = password 25 | 26 | @Column(nullable = false, length = 100, unique = true) 27 | var email: String = email 28 | 29 | fun toUserDto() = UserDto(this) 30 | } 31 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/user/domain/UserRepository.kt: -------------------------------------------------------------------------------- 1 | package com.template.user.domain 2 | 3 | import org.springframework.data.jpa.repository.JpaRepository 4 | import org.springframework.stereotype.Repository 5 | import java.util.Optional 6 | 7 | @Repository 8 | interface UserRepository : JpaRepository { 9 | fun findByEmail(email: String): Optional 10 | } 11 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/user/service/UserQueryHandler.kt: -------------------------------------------------------------------------------- 1 | package com.template.user.service 2 | 3 | import com.template.user.service.dto.UserDto 4 | 5 | interface UserQueryHandler { 6 | fun getUserInfo(userDto: UserDto): UserDto 7 | } 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/user/service/dto/UserDto.kt: -------------------------------------------------------------------------------- 1 | package com.template.user.service.dto 2 | 3 | import com.template.user.domain.User 4 | 5 | data class UserDto( 6 | val id: Int, 7 | val name: String, 8 | val password: String, 9 | val email: String 10 | ) { 11 | constructor(user: User) : this(user.id!!, user.name, user.password!!, user.email) 12 | } 13 | -------------------------------------------------------------------------------- /src/main/kotlin/com/template/user/service/impl/UserQueryHandlerImpl.kt: -------------------------------------------------------------------------------- 1 | package com.template.user.service.impl 2 | 3 | import com.template.user.domain.UserRepository 4 | import com.template.user.service.UserQueryHandler 5 | import com.template.user.service.dto.UserDto 6 | import org.springframework.stereotype.Service 7 | 8 | @Service 9 | class UserQueryHandlerImpl( 10 | private val userRepository: UserRepository 11 | ) : UserQueryHandler { 12 | override fun getUserInfo(userDto: UserDto) = userDto 13 | } 14 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | jpa: 3 | show-sql: false 4 | database-platform: org.hibernate.dialect.MySQL5InnoDBDialect 5 | open-in-view: false 6 | hibernate: 7 | ddl-auto: none 8 | properties: 9 | hibernate: 10 | dialect: org.hibernate.dialect.MySQL5InnoDBDialect 11 | 12 | datasource: 13 | url: ${DATASOURCE_URL} 14 | username: ${DATASOURCE_USERNAME} 15 | password: ${DATASOURCE_PASSWORD} 16 | driver-class-name: com.mysql.cj.jdbc.Driver 17 | 18 | management: 19 | endpoints: 20 | web: 21 | exposure: 22 | include: "health,info" 23 | 24 | info: 25 | application: 26 | author: Sangwoo Ra(robbyra@gmail.com) 27 | version: 0.1.0 28 | description: Template repository for building Spring Boot(MVC) Applications using Kotlin 29 | more_info: https://github.com/sang-w0o/spring-boot-mvc-kotlin-template 30 | 31 | jwt: 32 | secret: ${JWT_SECRET} 33 | accessTokenExp: 86400000 34 | refreshTokenExp: 604800000 35 | -------------------------------------------------------------------------------- /src/test/kotlin/com/template/integration/ActuatorTest.kt: -------------------------------------------------------------------------------- 1 | package com.template.integration 2 | 3 | import org.junit.jupiter.api.DisplayName 4 | import org.junit.jupiter.api.Test 5 | import org.springframework.test.web.servlet.get 6 | import java.net.URI 7 | 8 | class ActuatorTest : ApiIntegrationTest() { 9 | 10 | @DisplayName("Actuator - Health Check") 11 | @Test 12 | fun healthCheckApiIsOpen() { 13 | val test = mockMvc.get(URI.create("/actuator/health")) 14 | test.andExpect { 15 | status { isOk() } 16 | jsonPath("status") { value("UP") } 17 | } 18 | } 19 | 20 | @DisplayName("Actuator - Information") 21 | @Test 22 | fun infoApiIsOpen() { 23 | val test = mockMvc.get(URI.create("/actuator/info")) 24 | test.andExpect { 25 | status { isOk() } 26 | jsonPath("application.author") { exists() } 27 | jsonPath("application.version") { exists() } 28 | jsonPath("application.description") { exists() } 29 | jsonPath("application.more_info") { exists() } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/test/kotlin/com/template/integration/ApiIntegrationTest.kt: -------------------------------------------------------------------------------- 1 | package com.template.integration 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import com.template.auth.exception.UserIdNotFoundException 5 | import com.template.auth.tools.JwtTokenUtil 6 | import com.template.user.domain.User 7 | import com.template.user.domain.UserRepository 8 | import com.template.util.EMAIL 9 | import com.template.util.NAME 10 | import com.template.util.PASSWORD 11 | import org.junit.jupiter.api.AfterEach 12 | import org.junit.jupiter.api.BeforeEach 13 | import org.springframework.beans.factory.annotation.Autowired 14 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc 15 | import org.springframework.boot.test.context.SpringBootTest 16 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 17 | import org.springframework.test.context.ActiveProfiles 18 | import org.springframework.test.web.servlet.MockMvc 19 | import org.springframework.test.web.servlet.MockMvcResultMatchersDsl 20 | 21 | @SpringBootTest 22 | @ActiveProfiles("test") 23 | @AutoConfigureMockMvc 24 | abstract class ApiIntegrationTest { 25 | 26 | @Autowired 27 | protected lateinit var userRepository: UserRepository 28 | 29 | @Autowired 30 | protected lateinit var mockMvc: MockMvc 31 | 32 | @Autowired 33 | protected lateinit var jwtTokenUtil: JwtTokenUtil 34 | 35 | @Autowired 36 | protected lateinit var encoder: BCryptPasswordEncoder 37 | 38 | @Autowired 39 | protected lateinit var objectMapper: ObjectMapper 40 | 41 | @BeforeEach 42 | fun setUp() { 43 | val user = User( 44 | email = EMAIL, 45 | name = NAME, 46 | password = encoder.encode(PASSWORD) 47 | ) 48 | userRepository.save(user) 49 | } 50 | 51 | @AfterEach 52 | fun tearDown() { 53 | userRepository.deleteAll() 54 | } 55 | 56 | protected fun getUserId(): Int { 57 | val user = userRepository.findByEmail(EMAIL).orElseThrow { UserIdNotFoundException() } 58 | return user.id ?: -1 59 | } 60 | 61 | protected fun assertErrorResponse(dsl: MockMvcResultMatchersDsl, message: String) { 62 | dsl.jsonPath("timestamp") { exists() } 63 | dsl.jsonPath("status") { exists() } 64 | dsl.jsonPath("error") { exists() } 65 | dsl.jsonPath("message") { value(message) } 66 | dsl.jsonPath("path") { exists() } 67 | dsl.jsonPath("remote") { exists() } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/test/kotlin/com/template/integration/auth/AccessTokenUpdateTest.kt: -------------------------------------------------------------------------------- 1 | package com.template.integration.auth 2 | 3 | import com.jayway.jsonpath.JsonPath 4 | import com.template.auth.controller.request.AccessTokenUpdateRequest 5 | import com.template.integration.ApiIntegrationTest 6 | import io.jsonwebtoken.Jwts 7 | import io.jsonwebtoken.SignatureAlgorithm 8 | import io.kotest.matchers.shouldBe 9 | import org.junit.jupiter.api.DisplayName 10 | import org.junit.jupiter.api.Test 11 | import org.springframework.beans.factory.annotation.Value 12 | import org.springframework.http.MediaType 13 | import org.springframework.test.web.servlet.ResultActionsDsl 14 | import org.springframework.test.web.servlet.post 15 | import java.net.URI 16 | import java.util.Date 17 | 18 | class AccessTokenUpdateTest : ApiIntegrationTest() { 19 | 20 | companion object { 21 | private const val WRONG_TOKEN = "wrongToken" 22 | } 23 | 24 | @Value("\${jwt.secret}") 25 | lateinit var secretKey: String 26 | 27 | private fun apiCall(request: AccessTokenUpdateRequest): ResultActionsDsl { 28 | return mockMvc.post(URI.create("/v1/auth/update-token")) { 29 | contentType = MediaType.APPLICATION_JSON 30 | content = objectMapper.writeValueAsString(request) 31 | } 32 | } 33 | 34 | @DisplayName("Success") 35 | @Test 36 | fun updateToken_responseIsOkIfAllConditionsAreRight() { 37 | val userId = getUserId() 38 | val requestDto = AccessTokenUpdateRequest(jwtTokenUtil.generateRefreshToken(userId)) 39 | 40 | val result = apiCall(requestDto).andExpect { 41 | status { isOk() } 42 | jsonPath("accessToken") { exists() } 43 | }.andReturn() 44 | 45 | val accessToken = JsonPath.read(result.response.contentAsString, "$.accessToken") 46 | jwtTokenUtil.isTokenExpired(accessToken) shouldBe false 47 | } 48 | 49 | @DisplayName("실패 - 토큰이 잘못된 경우") 50 | @Test 51 | fun updateToken_responseIsUnAuthorizedIfRefreshTokenIsMalformed() { 52 | val requestDto = AccessTokenUpdateRequest(WRONG_TOKEN) 53 | apiCall(requestDto).andExpect { 54 | status { isUnauthorized() } 55 | assertErrorResponse(this, "잘못된 형식의 Jwt 토큰입니다.") 56 | } 57 | } 58 | 59 | @DisplayName("실패 - 잘못된 userId") 60 | @Test 61 | fun updateToken_responseIsUnAuthorizedIfUserIdIsInvalid() { 62 | val requestDto = AccessTokenUpdateRequest(jwtTokenUtil.generateRefreshToken(-1)) 63 | apiCall(requestDto).andExpect { 64 | status { isUnauthorized() } 65 | assertErrorResponse(this, "Unauthorized User Id.") 66 | } 67 | } 68 | 69 | @DisplayName("실패 - 만료된 토큰") 70 | @Test 71 | fun updateToken_responseIsUnAuthorizedIfAccessTokenIsExpired() { 72 | val userId = getUserId() 73 | val requestDto = AccessTokenUpdateRequest(generateExpiredRefreshToken(userId)) 74 | apiCall(requestDto).andExpect { 75 | status { isUnauthorized() } 76 | assertErrorResponse(this, "Jwt 토큰이 만료되었습니다.") 77 | } 78 | } 79 | 80 | private fun generateExpiredRefreshToken(userId: Int): String { 81 | val claims: MutableMap = mutableMapOf() 82 | claims["userId"] = userId 83 | return Jwts.builder() 84 | .setClaims(claims) 85 | .setIssuedAt(Date(System.currentTimeMillis() - 86400000 * 8)) 86 | .setExpiration(Date(System.currentTimeMillis() - 86400000 * 7)) 87 | .signWith(SignatureAlgorithm.HS256, secretKey.toByteArray()) 88 | .compact() 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/test/kotlin/com/template/integration/auth/LoginTest.kt: -------------------------------------------------------------------------------- 1 | package com.template.integration.auth 2 | 3 | import com.jayway.jsonpath.JsonPath 4 | import com.template.auth.controller.request.LoginRequest 5 | import com.template.integration.ApiIntegrationTest 6 | import com.template.util.EMAIL 7 | import com.template.util.PASSWORD 8 | import io.kotest.matchers.shouldBe 9 | import org.junit.jupiter.api.DisplayName 10 | import org.junit.jupiter.api.Test 11 | import org.springframework.http.MediaType 12 | import org.springframework.test.web.servlet.ResultActionsDsl 13 | import org.springframework.test.web.servlet.post 14 | import java.net.URI 15 | 16 | class LoginTest : ApiIntegrationTest() { 17 | 18 | companion object { 19 | const val WRONG_EMAIL = "wrong@wrong.com" 20 | const val WRONG_PASSWORD = "wrongPassword" 21 | } 22 | 23 | private fun apiCall(request: LoginRequest): ResultActionsDsl { 24 | return mockMvc.post(URI.create("/v1/auth/login")) { 25 | contentType = MediaType.APPLICATION_JSON 26 | content = objectMapper.writeValueAsString(request) 27 | } 28 | } 29 | 30 | @Test 31 | @DisplayName("로그인 성공") 32 | fun login_responseIsOkIfAllConditionsAreRight() { 33 | val userId = getUserId() 34 | val request = LoginRequest(EMAIL, PASSWORD) 35 | 36 | val result = apiCall(request).andExpect { 37 | status { isOk() } 38 | jsonPath("accessToken") { exists() } 39 | jsonPath("refreshToken") { exists() } 40 | }.andReturn() 41 | 42 | val accessToken = JsonPath.read(result.response.contentAsString, "$.accessToken") 43 | val refreshToken = JsonPath.read(result.response.contentAsString, "$.refreshToken") 44 | 45 | jwtTokenUtil.extractUserId(accessToken) shouldBe userId 46 | jwtTokenUtil.extractUserId(refreshToken) shouldBe userId 47 | } 48 | 49 | @Test 50 | @DisplayName("로그인 실패 - 없는 이메일") 51 | fun login_responseIsNotFoundIfEmailIsWrong() { 52 | val request = LoginRequest(WRONG_EMAIL, PASSWORD) 53 | 54 | apiCall(request).andExpect { 55 | status { isNotFound() } 56 | assertErrorResponse(this, "이메일 또는 비밀번호가 잘못되었습니다.") 57 | } 58 | } 59 | 60 | @Test 61 | @DisplayName("로그인 실패 - 비밀번호 오류") 62 | fun login_responseIsNotFoundIfPasswordIsWrong() { 63 | val request = LoginRequest(EMAIL, WRONG_PASSWORD) 64 | apiCall(request).andExpect { 65 | status { isNotFound() } 66 | assertErrorResponse(this, "이메일 또는 비밀번호가 잘못되었습니다.") 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/test/kotlin/com/template/integration/user/UserGetInfoTest.kt: -------------------------------------------------------------------------------- 1 | package com.template.integration.user 2 | 3 | import com.template.integration.ApiIntegrationTest 4 | import com.template.util.EMAIL 5 | import com.template.util.NAME 6 | import org.junit.jupiter.api.DisplayName 7 | import org.junit.jupiter.api.Test 8 | import org.springframework.test.web.servlet.ResultActionsDsl 9 | import org.springframework.test.web.servlet.get 10 | import java.net.URI 11 | 12 | class UserGetInfoTest : ApiIntegrationTest() { 13 | 14 | private fun apiCall(accessToken: String): ResultActionsDsl { 15 | return mockMvc.get(URI.create("/v1/user/")) { 16 | header("Authorization", "Bearer $accessToken") 17 | } 18 | } 19 | 20 | @DisplayName("Success") 21 | @Test 22 | fun getInfo_responseIsOkIfAllConditionsAreRight() { 23 | val userId = getUserId() 24 | val accessToken = jwtTokenUtil.generateAccessToken(userId) 25 | apiCall(accessToken).andExpect { 26 | status { isOk() } 27 | jsonPath("email") { value(EMAIL) } 28 | jsonPath("id") { value(userId) } 29 | jsonPath("name") { value(NAME) } 30 | }.andReturn() 31 | } 32 | 33 | @DisplayName("Fail - Invalid userId") 34 | @Test 35 | fun failWithInvalidUserId() { 36 | val accessToken = jwtTokenUtil.generateAccessToken(-1) 37 | apiCall(accessToken).andExpect { 38 | status { isUnauthorized() } 39 | assertErrorResponse(this, "Invalid userId.") 40 | } 41 | } 42 | 43 | @DisplayName("Fail - No Authorization header present.") 44 | @Test 45 | fun failWithAbsentAuthorizationHeader() { 46 | apiCall("").andExpect { 47 | status { isUnauthorized() } 48 | assertErrorResponse(this, "Jwt 헤더 값이 잘못되었습니다.") 49 | } 50 | } 51 | 52 | @DisplayName("Fail - Wrong Authorization header scheme.") 53 | @Test 54 | fun failWithWrongAuthorizationHeaderScheme() { 55 | mockMvc.get(URI.create("/v1/user/")) { 56 | header("Authorization", "Wrong accessToken") 57 | }.andExpect { 58 | status { isUnauthorized() } 59 | assertErrorResponse(this, "Scheme is not Bearer.") 60 | } 61 | } 62 | 63 | @DisplayName("Fail - AccessToken malformed.") 64 | @Test 65 | fun failWithMalformedAccessToken() { 66 | apiCall("MalformedToken").andExpect { 67 | status { isUnauthorized() } 68 | assertErrorResponse(this, "잘못된 형식의 Jwt 토큰입니다.") 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/test/kotlin/com/template/unit/BaseUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.template.unit 2 | 3 | import com.ninjasquad.springmockk.MockkBean 4 | import com.template.auth.tools.JwtProperties 5 | import com.template.user.domain.User 6 | import com.template.user.domain.UserRepository 7 | import com.template.util.* 8 | import io.jsonwebtoken.Jwts 9 | import io.jsonwebtoken.SignatureAlgorithm 10 | import org.junit.jupiter.api.extension.ExtendWith 11 | import org.springframework.boot.context.properties.EnableConfigurationProperties 12 | import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer 13 | import org.springframework.test.context.ContextConfiguration 14 | import org.springframework.test.context.junit.jupiter.SpringExtension 15 | import java.util.* 16 | 17 | @ExtendWith(SpringExtension::class) 18 | @EnableConfigurationProperties(JwtProperties::class) 19 | @ContextConfiguration(initializers = [ConfigDataApplicationContextInitializer::class]) 20 | abstract class BaseUnitTest { 21 | 22 | @MockkBean 23 | protected lateinit var userRepository: UserRepository 24 | 25 | protected var jwtProperties: JwtProperties = JwtProperties() 26 | 27 | init { 28 | jwtProperties.secret = JWT_SECRET 29 | jwtProperties.accessTokenExp = JWT_ACCESS_TOKEN_EXP 30 | jwtProperties.refreshTokenExp = JWT_REFRESH_TOKEN_EXP 31 | } 32 | 33 | protected fun getMockUser(): User { 34 | val savedUser = User(NAME, EMAIL, PASSWORD) 35 | savedUser.id = USER_ID 36 | return savedUser 37 | } 38 | 39 | protected fun generateTokenWithoutUserIdClaim(): String { 40 | return Jwts.builder() 41 | .setClaims(mutableMapOf()) 42 | .setIssuedAt(Date(System.currentTimeMillis())) 43 | .setExpiration(Date(System.currentTimeMillis() + EXTRA_TIME)) 44 | .signWith(SignatureAlgorithm.HS256, jwtProperties.secret.toByteArray()) 45 | .compact() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/test/kotlin/com/template/unit/auth/AccessTokenUpdateServiceUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.template.unit.auth 2 | 3 | import com.ninjasquad.springmockk.MockkBean 4 | import com.template.auth.exception.AuthenticateException 5 | import com.template.auth.service.AuthCommandHandler 6 | import com.template.auth.service.impl.AuthCommandHandlerImpl 7 | import com.template.auth.tools.JwtTokenUtil 8 | import com.template.unit.BaseUnitTest 9 | import com.template.util.TOKEN 10 | import com.template.util.USER_ID 11 | import io.kotest.assertions.throwables.shouldThrow 12 | import io.kotest.matchers.shouldBe 13 | import io.mockk.every 14 | import org.junit.jupiter.api.BeforeEach 15 | import org.junit.jupiter.api.DisplayName 16 | import org.junit.jupiter.api.Test 17 | import org.junit.jupiter.api.assertThrows 18 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 19 | 20 | class AccessTokenUpdateServiceUnitTest : BaseUnitTest() { 21 | 22 | private lateinit var authCommandHandler: AuthCommandHandler 23 | 24 | @MockkBean 25 | private lateinit var jwtTokenUtil: JwtTokenUtil 26 | 27 | private val encoder = BCryptPasswordEncoder(10) 28 | 29 | @BeforeEach 30 | fun setUp() { 31 | every { jwtTokenUtil.generateAccessToken(any()) } returns TOKEN 32 | every { jwtTokenUtil.generateRefreshToken(any()) } returns TOKEN 33 | every { jwtTokenUtil.extractUserId(any()) } returns USER_ID 34 | every { jwtTokenUtil.isTokenExpired(any()) } returns false 35 | authCommandHandler = AuthCommandHandlerImpl(jwtTokenUtil, userRepository, encoder) 36 | } 37 | 38 | @DisplayName("AccessToken 갱신 성공") 39 | @Test 40 | fun updateAccessToken_Success() { 41 | every { userRepository.existsById(any()) } returns true 42 | val refreshToken = jwtTokenUtil.generateRefreshToken(USER_ID) 43 | val result = authCommandHandler.updateAccessToken(refreshToken) 44 | jwtTokenUtil.isTokenExpired(result) shouldBe false 45 | } 46 | 47 | @DisplayName("AccessToken 갱신 실패 - 잘못된 userId인 경우") 48 | @Test 49 | fun updateAccessToken_FailWrongUserId() { 50 | every { userRepository.existsById(any()) } returns false 51 | val refreshToken = jwtTokenUtil.generateRefreshToken(USER_ID) 52 | val exception = assertThrows { authCommandHandler.updateAccessToken(refreshToken) } 53 | exception.message shouldBe "Unauthorized User Id." 54 | } 55 | 56 | @DisplayName("AccessToken 갱신 실패 - 만료된 refreshToken이 주어진 경우") 57 | @Test 58 | fun updateAccessToken_FailIfExpiredRefreshToken() { 59 | val refreshToken = jwtTokenUtil.generateRefreshToken(USER_ID) 60 | every { jwtTokenUtil.isTokenExpired(any()) } returns true 61 | val exception = shouldThrow { authCommandHandler.updateAccessToken(refreshToken) } 62 | exception.message shouldBe "RefreshToken has been expired." 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/test/kotlin/com/template/unit/auth/JwtTokenUtilTest.kt: -------------------------------------------------------------------------------- 1 | package com.template.unit.auth 2 | 3 | import com.template.auth.exception.AuthenticateException 4 | import com.template.auth.tools.JwtTokenUtil 5 | import com.template.unit.BaseUnitTest 6 | import com.template.user.service.dto.UserDto 7 | import com.template.util.EXTRA_TIME 8 | import com.template.util.USER_ID 9 | import com.template.util.generateExpiredToken 10 | import com.template.util.generateOtherSignatureToken 11 | import io.jsonwebtoken.Jwts 12 | import io.jsonwebtoken.SignatureAlgorithm 13 | import io.kotest.assertions.throwables.shouldThrow 14 | import io.kotest.matchers.shouldBe 15 | import io.kotest.matchers.types.shouldBeInstanceOf 16 | import io.mockk.every 17 | import org.junit.jupiter.api.BeforeEach 18 | import org.junit.jupiter.api.DisplayName 19 | import org.junit.jupiter.api.Test 20 | import java.util.* 21 | 22 | class JwtTokenUtilTest : BaseUnitTest() { 23 | 24 | private lateinit var jwtTokenUtil: JwtTokenUtil 25 | 26 | @BeforeEach 27 | fun setUp() { 28 | jwtTokenUtil = JwtTokenUtil(userRepository, jwtProperties) 29 | } 30 | 31 | @DisplayName("AccessToken 생성") 32 | @Test 33 | fun accessTokenIsCreated() { 34 | val accessToken = jwtTokenUtil.generateAccessToken(USER_ID) 35 | jwtTokenUtil.isTokenExpired(accessToken) shouldBe false 36 | } 37 | 38 | @DisplayName("RefreshToken 생성") 39 | @Test 40 | fun refreshTokenIsCreated() { 41 | val refreshToken = jwtTokenUtil.generateRefreshToken(USER_ID) 42 | jwtTokenUtil.isTokenExpired(refreshToken) shouldBe false 43 | } 44 | 45 | @DisplayName("만료된 AccessToken 검증") 46 | @Test 47 | fun oldAccessTokenIsExpired() { 48 | val oldAccessToken = generateExpiredToken(jwtProperties.accessTokenExp, jwtProperties.secret) 49 | val exception = shouldThrow { jwtTokenUtil.verify(oldAccessToken) } 50 | exception.message shouldBe "Jwt 토큰이 만료되었습니다." 51 | } 52 | 53 | @DisplayName("만료된 RefreshToken 검증") 54 | @Test 55 | fun oldRefreshTokenIsExpired() { 56 | val oldRefreshToken = generateExpiredToken(jwtProperties.refreshTokenExp, jwtProperties.secret) 57 | val exception = shouldThrow { jwtTokenUtil.verify(oldRefreshToken) } 58 | exception.message shouldBe "Jwt 토큰이 만료되었습니다." 59 | } 60 | 61 | @DisplayName("잘못된 형식의 Jwt 토큰") 62 | @Test 63 | fun wrongToken() { 64 | val wrongToken = "WRONG TOKEN" 65 | val exception = shouldThrow { jwtTokenUtil.verify(wrongToken) } 66 | exception.message shouldBe "잘못된 형식의 Jwt 토큰입니다." 67 | } 68 | 69 | @DisplayName("Signature가 잘못된 Jwt가 주어진 경우") 70 | @Test 71 | fun wrongSignatureToken() { 72 | val wrongToken = generateOtherSignatureToken(jwtProperties.accessTokenExp) 73 | val exception = shouldThrow { jwtTokenUtil.verify(wrongToken) } 74 | exception.message shouldBe "Jwt Signature이 잘못된 값입니다." 75 | } 76 | 77 | @DisplayName("Jwt Payload에 userId가 없는 경우") 78 | @Test 79 | fun jwtWithoutUserIdInPayload() { 80 | val wrongToken = Jwts.builder() 81 | .setClaims(mutableMapOf()) 82 | .setIssuedAt(Date(System.currentTimeMillis())) 83 | .setExpiration(Date(System.currentTimeMillis() + EXTRA_TIME)) 84 | .signWith(SignatureAlgorithm.HS256, jwtProperties.secret.toByteArray()) 85 | .compact() 86 | val exception = shouldThrow { jwtTokenUtil.extractUserId(wrongToken) } 87 | exception.message shouldBe "JWT Claim에 userId가 없습니다." 88 | } 89 | 90 | @DisplayName("정상적인 token일 경우 검증 성공") 91 | @Test 92 | fun correctTokenVerifySuccess() { 93 | val user = getMockUser() 94 | every { userRepository.findById(any()) } returns Optional.of(user) 95 | val accessToken = jwtTokenUtil.generateAccessToken(user.id!!) 96 | val authentication = jwtTokenUtil.verify(accessToken) 97 | authentication.principal.shouldBeInstanceOf() 98 | val userDto = authentication.principal as UserDto 99 | userDto.name shouldBe user.name 100 | userDto.email shouldBe user.email 101 | userDto.password shouldBe user.password 102 | userDto.id shouldBe user.id 103 | } 104 | 105 | @DisplayName("존재하지 않는 userId인 경우 검증 실패") 106 | @Test 107 | fun tokenWithInvalidUserId() { 108 | every { userRepository.findById(any()) } returns Optional.empty() 109 | val accessToken = jwtTokenUtil.generateAccessToken(USER_ID) 110 | val exception = shouldThrow { jwtTokenUtil.verify(accessToken) } 111 | exception.message shouldBe "Invalid userId." 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/test/kotlin/com/template/unit/auth/LoginUnitTest.kt: -------------------------------------------------------------------------------- 1 | package com.template.unit.auth 2 | 3 | import com.ninjasquad.springmockk.MockkBean 4 | import com.ninjasquad.springmockk.SpykBean 5 | import com.template.auth.exception.LoginException 6 | import com.template.auth.service.AuthCommandHandler 7 | import com.template.auth.service.impl.AuthCommandHandlerImpl 8 | import com.template.auth.tools.JwtTokenUtil 9 | import com.template.unit.BaseUnitTest 10 | import com.template.user.domain.User 11 | import com.template.util.EMAIL 12 | import com.template.util.NAME 13 | import com.template.util.PASSWORD 14 | import com.template.util.USER_ID 15 | import io.kotest.assertions.throwables.shouldThrow 16 | import io.kotest.matchers.shouldBe 17 | import io.mockk.every 18 | import org.junit.jupiter.api.BeforeEach 19 | import org.junit.jupiter.api.DisplayName 20 | import org.junit.jupiter.api.Test 21 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder 22 | import java.util.Optional 23 | 24 | class LoginUnitTest : BaseUnitTest() { 25 | 26 | private lateinit var authCommandHandler: AuthCommandHandler 27 | 28 | @MockkBean 29 | private lateinit var encoder: BCryptPasswordEncoder 30 | 31 | @SpykBean 32 | private lateinit var jwtTokenUtil: JwtTokenUtil 33 | 34 | @BeforeEach 35 | fun setUp() { 36 | authCommandHandler = AuthCommandHandlerImpl(jwtTokenUtil, userRepository, encoder) 37 | } 38 | 39 | @DisplayName("로그인 성공") 40 | @Test 41 | fun login_Success() { 42 | val user = User(NAME, EMAIL, PASSWORD) 43 | user.id = USER_ID 44 | every { userRepository.findByEmail(any()) } returns Optional.of(user) 45 | every { encoder.matches(any(), any()) } returns true 46 | val result = authCommandHandler.login(EMAIL, PASSWORD) 47 | jwtTokenUtil.isTokenExpired(result.first) shouldBe false 48 | jwtTokenUtil.isTokenExpired(result.second) shouldBe false 49 | } 50 | 51 | @DisplayName("로그인 실패 - 비밀번호 불일치") 52 | @Test 53 | fun login_FailIfWrongPassword() { 54 | every { encoder.matches(any(), any()) } returns false 55 | every { userRepository.findByEmail(any()) } returns Optional.of(getMockUser()) 56 | val exception = shouldThrow { authCommandHandler.login(EMAIL, PASSWORD) } 57 | exception.message shouldBe "이메일 또는 비밀번호가 잘못되었습니다." 58 | } 59 | 60 | @DisplayName("로그인 실패 - 존재하지 않는 이메일") 61 | @Test 62 | fun login_FailIfWrongEmail() { 63 | every { userRepository.findByEmail(any()) } returns Optional.empty() 64 | val exception = shouldThrow { authCommandHandler.login(EMAIL, PASSWORD) } 65 | exception.message shouldBe "이메일 또는 비밀번호가 잘못되었습니다." 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/test/kotlin/com/template/unit/user/UserGetInfoTest.kt: -------------------------------------------------------------------------------- 1 | package com.template.unit.user 2 | 3 | import com.template.unit.BaseUnitTest 4 | 5 | /** 6 | * UserService#getInfo()에 대한 단위 테스트를 작성합니다. 7 | * 이 메소드는 `JwtTokenUtil#getAuthentication()`에 매우 의존적이므로 8 | * `JwtTokenUtil#authentication()`에 대한 단위 테스트를 작성하는 것으로 대체했습니다. 9 | */ 10 | class UserGetInfoTest : BaseUnitTest() 11 | -------------------------------------------------------------------------------- /src/test/kotlin/com/template/util/TestUtils.kt: -------------------------------------------------------------------------------- 1 | package com.template.util 2 | 3 | import io.jsonwebtoken.Jwts 4 | import io.jsonwebtoken.SignatureAlgorithm 5 | import java.util.* 6 | 7 | const val EMAIL = "test@test.com" 8 | const val NAME = "testUserName" 9 | const val PASSWORD = "testPassword" 10 | const val USER_ID = 1 11 | const val TOKEN = "token" 12 | const val JWT_SECRET = "TestJwtSecretKey" 13 | const val JWT_ACCESS_TOKEN_EXP = 86400000 14 | const val JWT_REFRESH_TOKEN_EXP = 604800000 15 | const val EXTRA_TIME = 2000000 16 | 17 | fun generateExpiredToken(exp: Int, secret: String): String { 18 | val realExp = EXTRA_TIME + exp 19 | val claims: MutableMap = mutableMapOf() 20 | claims["userId"] = USER_ID 21 | return Jwts.builder() 22 | .setClaims(claims) 23 | .setIssuedAt(Date(System.currentTimeMillis() - realExp)) 24 | .setExpiration(Date(System.currentTimeMillis() - EXTRA_TIME)) 25 | .signWith(SignatureAlgorithm.HS256, secret.toByteArray()) 26 | .compact() 27 | } 28 | 29 | fun generateOtherSignatureToken(exp: Int): String { 30 | val claims: MutableMap = mutableMapOf() 31 | claims["userId"] = USER_ID 32 | return Jwts.builder() 33 | .setClaims(claims) 34 | .setIssuedAt(Date(System.currentTimeMillis())) 35 | .setExpiration(Date(System.currentTimeMillis() + exp)) 36 | .signWith(SignatureAlgorithm.HS256, "Other Signature") 37 | .compact() 38 | } 39 | -------------------------------------------------------------------------------- /src/test/resources/application-test.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | jpa: 3 | show-sql: false 4 | open-in-view: false 5 | hibernate: 6 | ddl-auto: create 7 | properties: 8 | hibernate: 9 | dialect: org.hibernate.dialect.H2Dialect 10 | datasource: 11 | url: jdbc:h2:mem:test 12 | driver-class-name: org.h2.Driver 13 | 14 | jwt: 15 | secret: TestJwtSecretKey 16 | accessTokenExp: 86400000 17 | refreshTokenExp: 604800000 18 | --------------------------------------------------------------------------------