diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..c2065bc26 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..7632a514c --- /dev/null +++ b/build.gradle @@ -0,0 +1,82 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '2.7.12' + id 'io.spring.dependency-management' version '1.0.15.RELEASE' + id "org.asciidoctor.jvm.convert" version "3.3.2" +} + +group = 'com.codestates.stackoverflow' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '11' + + +repositories { + mavenCentral() +} + + +ext { + set('snippetsDir', file("build/generated-snippets")) +} + +configurations { + asciidoctorExtensions +} + +dependencies { + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' + implementation 'org.springframework.restdocs:spring-restdocs-core' + + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.h2database:h2' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + implementation 'org.mapstruct:mapstruct:1.5.1.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.1.Final' + implementation 'com.google.code.gson:gson' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + +} + +tasks.named('test') { + outputs.dir snippetsDir + useJUnitPlatform() +} + + +tasks.named('asciidoctor') { + configurations "asciidoctorExtensions" + inputs.dir snippetsDir + dependsOn test +} + + +task copyDocument(type: Copy) { + dependsOn asciidoctor + from file("${asciidoctor.outputDir}") + into file("src/main/resources/static/docs") +} + +build { + dependsOn copyDocument +} + +// (10) +bootJar { + dependsOn copyDocument // (10-1) + from ("${asciidoctor.outputDir}") { // (10-2) + into 'static/docs' // (10-3) + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..249e5832f Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..774fae876 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..a69d9cb6c --- /dev/null +++ b/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..f127cfd49 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..43bcca00b --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'stackoverflow' diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..e6665b162 --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,92 @@ += Stackoverflow Application +:sectnums: +:toc: left +:toclevels: 4 +:toc-title: Table of Contents +:source-highlighter: prettify + +Writer : Park Jeein + +v1.0.0. 2023.06.14 + +*** +== MemberController +=== 회원 등록 + +.http-request +include::{snippets}/post-member/http-request.adoc[] + +.request-fields +include::{snippets}/post-member/request-fields.adoc[] + + +.http-response +include::{snippets}/post-member/http-response.adoc[] + +.response-headers +include::{snippets}/post-member/response-headers.adoc[] + + +=== 회원 정보 수정 + +.http-request +include::{snippets}/patch-member/http-request.adoc[] + +.path-parameters +include::{snippets}/patch-member/path-parameters.adoc[] + +.request-fields +include::{snippets}/patch-member/request-fields.adoc[] + +.http-response +include::{snippets}/patch-member/http-response.adoc[] + + +=== 회원 정보 조회 + +.http-request +include::{snippets}/get-member/http-request.adoc[] + +.path-parameters +include::{snippets}/get-member/path-parameters.adoc[] + +.http-response +include::{snippets}/get-member/http-response.adoc[] + + +=== 전체 회원 정보 조회 + +.http-request +include::{snippets}/get-members/http-request.adoc[] + +.request-parameters +include::{snippets}/get-members/request-parameters.adoc[] + +.http-response +include::{snippets}/get-members/http-response.adoc[] + + +=== 회원 탈퇴 + +.http-request +include::{snippets}/delete-member/http-request.adoc[] + +.path-parameters +include::{snippets}/delete-member/path-parameters.adoc[] + +.http-response +include::{snippets}/delete-member/http-response.adoc[] + + +=== 전체 회원 삭제 + + +.http-request +include::{snippets}/delete-members/http-request.adoc[] + +.http-response +include::{snippets}/delete-members/http-response.adoc[] + + + + diff --git a/src/main/java/com/codestates/stackoverflow/StackoverflowApplication.java b/src/main/java/com/codestates/stackoverflow/StackoverflowApplication.java new file mode 100644 index 000000000..6554ca487 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/StackoverflowApplication.java @@ -0,0 +1,15 @@ +package com.codestates.stackoverflow; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@SpringBootApplication +public class StackoverflowApplication { + + public static void main(String[] args) { + SpringApplication.run(StackoverflowApplication.class, args); + } + +} diff --git a/src/main/java/com/codestates/stackoverflow/advice/GlobalExceptionAdvice.java b/src/main/java/com/codestates/stackoverflow/advice/GlobalExceptionAdvice.java new file mode 100644 index 000000000..571bf7ef7 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/advice/GlobalExceptionAdvice.java @@ -0,0 +1,90 @@ +package com.codestates.stackoverflow.advice; + +import com.codestates.stackoverflow.exception.BusinessLogicException; +import com.codestates.stackoverflow.response.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import javax.validation.ConstraintViolationException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionAdvice { + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleMethodArgumentNotValidException( + MethodArgumentNotValidException e) { + final ErrorResponse response = ErrorResponse.of(e.getBindingResult()); + + return response; + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleConstraintViolationException( + ConstraintViolationException e) { + final ErrorResponse response = ErrorResponse.of(e.getConstraintViolations()); + + return response; + } + + @ExceptionHandler + public ResponseEntity handleBusinessLogicException(BusinessLogicException e) { + final ErrorResponse response = ErrorResponse.of(e.getExceptionCode()); + + return new ResponseEntity<>(response, HttpStatus.valueOf(e.getExceptionCode() + .getStatus())); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + public ErrorResponse handleHttpRequestMethodNotSupportedException( + HttpRequestMethodNotSupportedException e) { + + final ErrorResponse response = ErrorResponse.of(HttpStatus.METHOD_NOT_ALLOWED); + + return response; + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleHttpMessageNotReadableException( + HttpMessageNotReadableException e) { + + final ErrorResponse response = ErrorResponse.of(HttpStatus.BAD_REQUEST, + "Required request body is missing"); + + return response; + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleMissingServletRequestParameterException( + MissingServletRequestParameterException e) { + + final ErrorResponse response = ErrorResponse.of(HttpStatus.BAD_REQUEST, + e.getMessage()); + + return response; + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ErrorResponse handleException(Exception e) { + log.error("# handle Exception", e); + // TODO 애플리케이션의 에러는 에러 로그를 로그에 기록하고, 관리자에게 이메일이나 카카오톡,슬랙 등으로 알려주는 로직이 있는게 좋다. + + final ErrorResponse response = ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR); + + return response; + } +} + diff --git a/src/main/java/com/codestates/stackoverflow/audit/Auditable.java b/src/main/java/com/codestates/stackoverflow/audit/Auditable.java new file mode 100644 index 000000000..37c628888 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/audit/Auditable.java @@ -0,0 +1,29 @@ +package com.codestates.stackoverflow.audit; + +import lombok.Getter; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.Column; +import javax.persistence.EntityListeners; +import javax.persistence.MappedSuperclass; +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class Auditable { + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "LAST_MODIFIED_AT") + private LocalDateTime modifiedAt; + + @CreatedBy + @Column(updatable = false) + private String createdBy; +} diff --git a/src/main/java/com/codestates/stackoverflow/auth/AuthenticationSuccessHandler/OAuth2MemberSuccessHandler.java b/src/main/java/com/codestates/stackoverflow/auth/AuthenticationSuccessHandler/OAuth2MemberSuccessHandler.java new file mode 100644 index 000000000..47787b2eb --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/auth/AuthenticationSuccessHandler/OAuth2MemberSuccessHandler.java @@ -0,0 +1,105 @@ +package com.codestates.stackoverflow.auth.AuthenticationSuccessHandler; + + +import com.codestates.stackoverflow.member.entity.Member; +import com.codestates.stackoverflow.member.service.MemberService; +import com.codestates.stackoverflow.auth.jwt.JwtTokenizer; +import com.codestates.stackoverflow.auth.utils.CustomAuthorityUtils; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriComponentsBuilder; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URI; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +public class OAuth2MemberSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private final JwtTokenizer jwtTokenizer; + private final CustomAuthorityUtils authorityUtils; + private final MemberService memberService; + + public OAuth2MemberSuccessHandler(JwtTokenizer jwtTokenizer, + CustomAuthorityUtils authorityUtils, + MemberService memberService) { + this.jwtTokenizer = jwtTokenizer; + this.authorityUtils = authorityUtils; + this.memberService = memberService; + } + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + var oAuth2User = (OAuth2User) authentication.getPrincipal(); + String email = String.valueOf(oAuth2User.getAttributes().get("email")); + List authorities = authorityUtils.createRoles(email); + saveMember(email); + redirect(request, response, email, authorities); + } + private void saveMember(String email) { + Member member = new Member(email); + memberService.createMember(member); + } + private void redirect(HttpServletRequest request, HttpServletResponse response, String username, List authorities) throws IOException { + String accessToken = delegateAccessToken(username, authorities); + String refreshToken = delegateRefreshToken(username); + String uri = createURI(accessToken, refreshToken).toString(); + getRedirectStrategy().sendRedirect(request, response, uri); + } + private String delegateAccessToken(String username, List authorities) { + Map claims = new HashMap<>(); + claims.put("username", username); + claims.put("roles", authorities); + String subject = username; + Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes()); + + String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); + + String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey); + + return accessToken; + } + + private String delegateRefreshToken(String username) { + String subject = username; + Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes()); + String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); + + String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey); + + return refreshToken; + } + + private URI createURI(String accessToken, String refreshToken) { + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("access_token", accessToken); + queryParams.add("refresh_token", refreshToken); + + return UriComponentsBuilder + .newInstance() + .scheme("http") + .host("localhost") + .port(8080) + path("/receive-token.html") + .queryParams(queryParams) + .build() + .toUri(); + } + +} + + + + + + + + +} + diff --git a/src/main/java/com/codestates/stackoverflow/auth/dto/LoginDto.java b/src/main/java/com/codestates/stackoverflow/auth/dto/LoginDto.java new file mode 100644 index 000000000..59d809299 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/auth/dto/LoginDto.java @@ -0,0 +1,10 @@ +package com.codestates.stackoverflow.auth.dto; + +import lombok.Getter; + +@Getter +public class LoginDto { + private String username; + private String password; + +} diff --git a/src/main/java/com/codestates/stackoverflow/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/codestates/stackoverflow/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 000000000..51270111a --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,95 @@ +package com.codestates.stackoverflow.auth.filter; + +import com.codestates.stackoverflow.auth.dto.LoginDto; +import com.codestates.stackoverflow.auth.jwt.JwtTokenizer; +import com.codestates.stackoverflow.member.entity.Member; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.SneakyThrows; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +//클라이언트의 로그인 인증 요청을 처리하는 엔트리포인트 역할. +public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + //로그인 인증 정보를 전달받아 UserDetailsService와 인터랙션 한 뒤 인증 여부 판단 + private final AuthenticationManager authenticationManager; + + //클라이언트가 인증에 성공할 경우, JWT를 생성 및 발급하는 역할 + private final JwtTokenizer jwtTokenizer; + + public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) { + this.authenticationManager = authenticationManager; + this.jwtTokenizer = jwtTokenizer; + } + + //메서드 내부에서 인증을 시도하는 로직. + @SneakyThrows + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response){ + //클라이언트에서 전송한 Username, Password를 DTO 클래스로 역직렬화하기 위해 ObjectMapper 인스턴스 생성 + ObjectMapper objectMapper = new ObjectMapper(); + + //ServletInputStream을 LoginDto 클래스의 객체로 역직렬화 + LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class); + + //UsernamePasswordAuthenticationToken을 생성 + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword()); + + //UsernamePasswordAuthenticationToken을 AuthenticationManager에게 전달하면서 인증 처리 위임 + return authenticationManager.authenticate(authenticationToken); + } + + //클라이언트의 인증 정보를 이용해 인증에 성공할 경우 호출된다. + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authReesult) throws ServletException, IOException { + //AuthenticationManger 내부에서 인증에 성공하면 인증된 Authentication 객체가 생성되면서 principal 필드에 Member 객체 할당 + Member member = (Member) authReesult.getPrincipal(); + + String accessToken = delegateAccessToken(member); + String refreshToken = delegateRefreshToken(member); + + //response header에 Access Token 추가. 클라이언트 측에서 백엔드 애플리케이션 측에 요청을 보낼 때마다 request header에 추가해서 클라이언트 측의 자격을 증명하는 데 사용된다. + response.setHeader("Authorization", "Bearer " + accessToken); + //repsonse header에 Refresh Token 추가. Access Token이 만료될 경우, 클라이언트 측이 Access Token을 새로 발급받기 위해 클라이언트에게 추가적으로 제공될 수 있음. + response.setHeader("Refresh", refreshToken); + + this.getSuccessHandler().onAuthenticationSuccess(request, response, authReesult); + + } + + + private String delegateAccessToken(Member member) { + Map claims = new HashMap<>(); + claims.put("username", member.getEmail()); + claims.put("roles", member.getRoles()); + + String subject = member.getEmail(); + Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes()); + + String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); + + String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey); + + return accessToken; + } + + private String delegateRefreshToken(Member member) { + String subject = member.getEmail(); + Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes()); + String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); + + String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey); + + return refreshToken; + } +} diff --git a/src/main/java/com/codestates/stackoverflow/auth/filter/JwtVerificationFilter.java b/src/main/java/com/codestates/stackoverflow/auth/filter/JwtVerificationFilter.java new file mode 100644 index 000000000..fb0269744 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/auth/filter/JwtVerificationFilter.java @@ -0,0 +1,84 @@ +package com.codestates.stackoverflow.auth.filter; + +import com.codestates.stackoverflow.auth.jwt.JwtTokenizer; +import com.codestates.stackoverflow.auth.utils.CustomAuthorityUtils; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.security.SignatureException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +//클라이언트 측에서 전송된 request header에 포함된 JWT에 대해 검증 작업을 수행하는 JwtVerificationFilter +public class JwtVerificationFilter extends OncePerRequestFilter { //request 당 한 번만 실행되는 Security Filter를 구현할 수 있다. + private final JwtTokenizer jwtTokenizer; //JWT를 검증하고 Claims를 얻는 데 사용됨. + + private final CustomAuthorityUtils authorityUtils;//JWT 검증에 성공하면 Authentication 객체를 채율 사용자의 권한을 생성하는 데 사용됨. + + public JwtVerificationFilter(JwtTokenizer jwtTokenizer, CustomAuthorityUtils authorityUtils) { + this.jwtTokenizer = jwtTokenizer; + this.authorityUtils = authorityUtils; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + //예외 처리가 추가됨. 추가된 애트리뷰트는 AthenticationEntryPoint에서 사용할 수 있다. + //예외가 발생하게 되면 SecurityContext에 Authentication 객체가 저장되지 않음. + try { + Map claims = verifyJws(request); + setAuthenticationToContext(claims); + } catch (SignatureException se) { + request.setAttribute("exception", se); + } catch (ExpiredJwtException ee) { + request.setAttribute("exception", ee); + } catch (Exception e) { + request.setAttribute("exception", e); + } + + //JWT의 서명 검증에 성공하고, Security Context에 Authentication을 저장한 뒤에는 다음 Security Filter를 호출한다. + filterChain.doFilter(request, response); + } + + //특정 조건에 부합하면(true이면) 해당 Filter의 동작을 수행하지 않고 다음 Filter로 건너뛰도록 해준다. + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + //JWT가 Authorization header에 포함되지 않았다면 JWT 자격 증명이 필요하지 않은 리소스에 대한 요청이라고 판단하고 다음 Filter로 처리를 넘긴다. + String authorization = request.getHeader("Authorization"); + return authorization == null || !authorization.startsWith("Bearer"); + } + + //JWT를 검증하는 데 사용되는 private 메서드 + private Map verifyJws(HttpServletRequest request) { + //request의 header에서 JWT를 얻고 있다. + String jws = request.getHeader("Authorization").replace("Bearer ", ""); + + //JWT 서명을 검증하기 위한 Secret Key를 얻는다. + String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); + //JWT에서 Claims를 파싱 -> 정상적으로 파싱되면 서명 검증 역시 자연스럽게 성공한 것. + Map claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody(); + + return claims; + } + + //Authentication 객체를 SecurityContext에 저장하기 위한 priavte 메서드 + private void setAuthenticationToContext(Map claims) { + String username = (String) claims.get("username"); + List authorities = authorityUtils.createAuthorities((List) claims.get("roles")); + Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authentication); + + } + +} + + diff --git a/src/main/java/com/codestates/stackoverflow/auth/handler/MemberAccessDeniedHandler.java b/src/main/java/com/codestates/stackoverflow/auth/handler/MemberAccessDeniedHandler.java new file mode 100644 index 000000000..92525fc14 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/auth/handler/MemberAccessDeniedHandler.java @@ -0,0 +1,26 @@ +package com.codestates.stackoverflow.auth.handler; + +import com.codestates.stackoverflow.auth.utils.ErrorResponder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + + +//클라이언트가 요청한 리소스에 대한 적절한 권한이 없을 경우 호출되는 핸들러 +@Slf4j +@Component +public class MemberAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + ErrorResponder.sendErrorResponse(response, HttpStatus.FORBIDDEN); + log.warn("Forbidden error happend: {}", accessDeniedException.getMessage()); + } +} diff --git a/src/main/java/com/codestates/stackoverflow/auth/handler/MemberAuthenticationEntryPoint.java b/src/main/java/com/codestates/stackoverflow/auth/handler/MemberAuthenticationEntryPoint.java new file mode 100644 index 000000000..f9da9c622 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/auth/handler/MemberAuthenticationEntryPoint.java @@ -0,0 +1,32 @@ +package com.codestates.stackoverflow.auth.handler; + +import com.codestates.stackoverflow.auth.utils.ErrorResponder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +//AuthenticationException이 발생할 때의 핸들러 역할을 한다. +@Slf4j +@Component +public class MemberAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + Exception exception = (Exception) request.getAttribute("exception"); + ErrorResponder.sendErrorResponse(response, HttpStatus.UNAUTHORIZED); + logExceptionMessage(authException, exception); + } + + //예외 메시지를 기록하는 내부 메서드 + private void logExceptionMessage(AuthenticationException authException, Exception exception) { + String message = exception != null ? exception.getMessage() : authException.getMessage(); + log.warn("Unauthorized error happend: {}", message); + } +} diff --git a/src/main/java/com/codestates/stackoverflow/auth/handler/MemberAuthenticationFailureHandler.java b/src/main/java/com/codestates/stackoverflow/auth/handler/MemberAuthenticationFailureHandler.java new file mode 100644 index 000000000..20802551d --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/auth/handler/MemberAuthenticationFailureHandler.java @@ -0,0 +1,47 @@ +package com.codestates.stackoverflow.auth.handler; + +import com.codestates.stackoverflow.response.ErrorResponse; +import com.google.gson.Gson; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Slf4j +public class MemberAuthenticationFailureHandler implements AuthenticationFailureHandler { + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + log.error("# Authentication failed: {}", exception.getMessage()); + + sendErrorResponse(response); + } + + private void sendErrorResponse(HttpServletResponse response) throws IOException { + //Error 정보가 담긴 객체를 JSON 문자열로 변환하는 데 사용되는 Gson 라이브러리의 인스턴스를 생성한다. + Gson gson = new Gson(); + + //ErrorResponse 객체를 생성 + ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.UNAUTHORIZED); + + //response의 ContentType이 application/json 이라는 것을 클라이언트에게 알려주기 위함. + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + + //Gson을 이용해 ErrorResponse 객체를 JSON 포맷 문자열로 변환 후, 출력 스트림 생성 + response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class)); + /* + { + "status": 401, + "message": "Unauthorized" + } + */ + } + +} diff --git a/src/main/java/com/codestates/stackoverflow/auth/handler/MemberAuthenticationSuccessHandler.java b/src/main/java/com/codestates/stackoverflow/auth/handler/MemberAuthenticationSuccessHandler.java new file mode 100644 index 000000000..97c828bb6 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/auth/handler/MemberAuthenticationSuccessHandler.java @@ -0,0 +1,32 @@ +package com.codestates.stackoverflow.auth.handler; + +import com.codestates.stackoverflow.member.entity.Member; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Slf4j +public class MemberAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + // 인증 성공 후 로그를 기록. + log.info("# Authenticated successfully!"); + + Member member = (Member)authentication.getPrincipal(); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(HttpStatus.OK.value()); + + //HttpServletResponse의 getWriter는 PrintWriter 객체를 반환 + response.getWriter().write("Authentication success! User : " + member.getName()); + + //PrintWriter의 버퍼를 비우고, 현재까지 버퍼에 쌓인 데이터를 실제 출력 스트림으로 보냄. + response.getWriter().flush(); + } +} diff --git a/src/main/java/com/codestates/stackoverflow/auth/jwt/JwtTokenizer.java b/src/main/java/com/codestates/stackoverflow/auth/jwt/JwtTokenizer.java new file mode 100644 index 000000000..45fe1e14e --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/auth/jwt/JwtTokenizer.java @@ -0,0 +1,98 @@ +package com.codestates.stackoverflow.auth.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.io.Encoders; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Calendar; +import java.util.Date; +import java.util.Map; + +@Component +public class JwtTokenizer { + @Getter + @Value("${jwt.key}") + private String secretKey; + + @Getter + @Value("${jwt.access-token-expiration-minutes}") + private int accessTokenExpirationMinutes; + + @Getter + @Value("${jwt.refresh-token-expiration-minutes}") + private int refreshTokenExpirationMinutes; + + public String encodeBase64SecretKey(String secretKey){ + return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8)); + } + + public String generateAccessToken(Map claims, + String subject, + Date expiration, + String base64EncodedSecretKey){ + Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey); + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(Calendar.getInstance().getTime()) + .setExpiration(expiration) + .signWith(key) + .compact(); + } + + + public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey){ + Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey); + + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(Calendar.getInstance().getTime()) + .setExpiration(expiration) + .signWith(key) + .compact(); + + } + + public Jws getClaims(String jws, String base64EncodedSecretKey){ + Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey); + + Jws claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(jws); + + return claims; + } + + public void verifySignature(String jws, String base64EncodedSecretKey){ + Key key = getKeyFromBase64EncodedKey(base64EncodedSecretKey); + + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(jws); + } + + + public Date getTokenExpiration(int expirationMinutes){ + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.MINUTE, expirationMinutes); + Date expiration = calendar.getTime(); + + return expiration; + } + + private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) { + byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey); + Key key = Keys.hmacShaKeyFor(keyBytes); + return key; + } +} diff --git a/src/main/java/com/codestates/stackoverflow/auth/userdetails/MemberDetailsService.java b/src/main/java/com/codestates/stackoverflow/auth/userdetails/MemberDetailsService.java new file mode 100644 index 000000000..50d112cec --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/auth/userdetails/MemberDetailsService.java @@ -0,0 +1,81 @@ +package com.codestates.stackoverflow.auth.userdetails; + +import com.codestates.stackoverflow.auth.utils.CustomAuthorityUtils; +import com.codestates.stackoverflow.exception.BusinessLogicException; +import com.codestates.stackoverflow.exception.ExceptionCode; +import com.codestates.stackoverflow.member.entity.Member; +import com.codestates.stackoverflow.member.repository.MemberRepository; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Optional; + + +@Component +public class MemberDetailsService implements UserDetailsService { + private final MemberRepository memberRepository; + private final CustomAuthorityUtils authorityUtils; + + public MemberDetailsService(MemberRepository memberRepository, CustomAuthorityUtils authorityUtils) { + this.memberRepository = memberRepository; + this.authorityUtils = authorityUtils; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Optional optionalMember = memberRepository.findByEmail(username); + Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND)); + return new MemberDetails(findMember); + } + + + public final class MemberDetails extends Member implements UserDetails{ + MemberDetails(Member member){ + setMemberId(member.getMemberId()); + setEmail(member.getEmail()); + setName(member.getName()); + setPassword(member.getPassword()); + setRoles(member.getRoles()); + } + + @Override + public Collection getAuthorities(){ + return authorityUtils.createAuthorities(this.getRoles()); + } + + @Override + public String getUsername() { + return getEmail(); + } + + @Override + public Long getMemberId(){ + return super.getMemberId(); + } + + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + } +} diff --git a/src/main/java/com/codestates/stackoverflow/auth/utils/CustomAuthorityUtils.java b/src/main/java/com/codestates/stackoverflow/auth/utils/CustomAuthorityUtils.java new file mode 100644 index 000000000..a236228fb --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/auth/utils/CustomAuthorityUtils.java @@ -0,0 +1,34 @@ +package com.codestates.stackoverflow.auth.utils; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class CustomAuthorityUtils { + @Value("${mail.address.admin}") + private String adminMainAddress; + + private final List ADMIN_ROLES_STRING = List.of("ADMIN", "USER"); + + private final List USER_ROLES_STRING = List.of("USER"); + + + public List createAuthorities(List roles) { + List authorities = roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_"+role)) + .collect(Collectors.toList()); + + return authorities; + } + + public List createRoles(String email) { + if(email.equals(adminMainAddress)) + return ADMIN_ROLES_STRING; + return USER_ROLES_STRING; + } +} diff --git a/src/main/java/com/codestates/stackoverflow/auth/utils/ErrorResponder.java b/src/main/java/com/codestates/stackoverflow/auth/utils/ErrorResponder.java new file mode 100644 index 000000000..c186b53c2 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/auth/utils/ErrorResponder.java @@ -0,0 +1,20 @@ +package com.codestates.stackoverflow.auth.utils; + +import com.codestates.stackoverflow.response.ErrorResponse; +import com.google.gson.Gson; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +//ErrorResponse를 출력 스트림으로 생성하는 역할을 하는 클래스. +public class ErrorResponder { + public static void sendErrorResponse(HttpServletResponse response, HttpStatus status) throws IOException { + Gson gson = new Gson(); + ErrorResponse errorResponse = ErrorResponse.of(status); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(status.value()); + response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class)); + } +} diff --git a/src/main/java/com/codestates/stackoverflow/config/SecurityConfiguration.java b/src/main/java/com/codestates/stackoverflow/config/SecurityConfiguration.java new file mode 100644 index 000000000..c8a11649a --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/config/SecurityConfiguration.java @@ -0,0 +1,109 @@ +package com.codestates.stackoverflow.config; + +import com.codestates.stackoverflow.auth.filter.JwtAuthenticationFilter; +import com.codestates.stackoverflow.auth.filter.JwtVerificationFilter; +import com.codestates.stackoverflow.auth.handler.MemberAccessDeniedHandler; +import com.codestates.stackoverflow.auth.handler.MemberAuthenticationEntryPoint; +import com.codestates.stackoverflow.auth.handler.MemberAuthenticationFailureHandler; +import com.codestates.stackoverflow.auth.handler.MemberAuthenticationSuccessHandler; +import com.codestates.stackoverflow.auth.jwt.JwtTokenizer; +import com.codestates.stackoverflow.auth.utils.CustomAuthorityUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +@Configuration +@EnableWebSecurity(debug = true) +public class SecurityConfiguration { + private final JwtTokenizer jwtTokenizer; + private final CustomAuthorityUtils authorityUtils; + + + public SecurityConfiguration(JwtTokenizer jwtTokenizer, CustomAuthorityUtils authorityUtils) { + this.jwtTokenizer = jwtTokenizer; + this.authorityUtils = authorityUtils; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ + http + .headers().frameOptions().sameOrigin() + .and() + .csrf().disable() + .cors(Customizer.withDefaults()) + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //세션을 생성하지 않도록 설정한다. + .and() + .formLogin().disable() + .httpBasic().disable() + .exceptionHandling() + .authenticationEntryPoint(new MemberAuthenticationEntryPoint()) //예외 처리 추가 + .accessDeniedHandler(new MemberAccessDeniedHandler()) //예외 처리 추가 + .and() + .apply(new CustomFilterConfigurer())//커스터마이징된 Configuration 추가 + .and() + .authorizeHttpRequests(authorize -> authorize + .antMatchers(HttpMethod.POST, "/users").permitAll() + .antMatchers(HttpMethod.PATCH, "/users/**").hasRole("USER") + //로그인하지 않아도 모든 사용자 정보를 GET할 수 있다.(우선) +// .antMatchers(HttpMethod.GET, "/users").hasRole("ADMIN") +// .antMatchers(HttpMethod.GET, "/users/**").hasAnyRole("USER", "ADMIN") + .antMatchers(HttpMethod.DELETE, "/users").hasRole("ADMIN") + .antMatchers(HttpMethod.DELETE, "/users/**").hasAnyRole("USER", "ADMIN") + .anyRequest().permitAll() + ); + + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder(){ + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + + @Bean + CorsConfigurationSource corsConfigurationSource(){ + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + //JwtAuthenticationFilter를 등록하는 역할 + public class CustomFilterConfigurer extends AbstractHttpConfigurer{ + @Override + public void configure(HttpSecurity builder) throws Exception{ + AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); + + JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer); + jwtAuthenticationFilter.setFilterProcessesUrl("/users/login"); + jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler()); + jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler()); + + JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils); + + + builder + .addFilter(jwtAuthenticationFilter) //JwtAuthenticationFilter를 Spring Security Filter Chain에 추가한다. + .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class); //JwtAuthenticationFilter에서 로그인 인증을 성공한 후 발급받은 JWT가 클라이언트의 request header에 포함되어 있을 경우에만 동작한다. + + } + } + +} diff --git a/src/main/java/com/codestates/stackoverflow/dto/MultiResponseDto.java b/src/main/java/com/codestates/stackoverflow/dto/MultiResponseDto.java new file mode 100644 index 000000000..1626d6ebc --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/dto/MultiResponseDto.java @@ -0,0 +1,22 @@ +package com.codestates.stackoverflow.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class MultiResponseDto { + List data; + private PageInfo pageInfo; + + public MultiResponseDto(List data, Page page){ + this.data = data; + this.pageInfo = new PageInfo(page.getNumber() + 1, + page.getSize(), page.getTotalElements(), page.getTotalPages()); + } + + +} diff --git a/src/main/java/com/codestates/stackoverflow/dto/PageInfo.java b/src/main/java/com/codestates/stackoverflow/dto/PageInfo.java new file mode 100644 index 000000000..68daa8a26 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/dto/PageInfo.java @@ -0,0 +1,13 @@ +package com.codestates.stackoverflow.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class PageInfo { + private int page; + private int size; + private long totalElements; + private int totalPages; +} diff --git a/src/main/java/com/codestates/stackoverflow/dto/SingleResponseDto.java b/src/main/java/com/codestates/stackoverflow/dto/SingleResponseDto.java new file mode 100644 index 000000000..d5d5e44a0 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/dto/SingleResponseDto.java @@ -0,0 +1,10 @@ +package com.codestates.stackoverflow.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class SingleResponseDto { + private T data; +} diff --git a/src/main/java/com/codestates/stackoverflow/exception/BusinessLogicException.java b/src/main/java/com/codestates/stackoverflow/exception/BusinessLogicException.java new file mode 100644 index 000000000..ed2daac9b --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/exception/BusinessLogicException.java @@ -0,0 +1,13 @@ +package com.codestates.stackoverflow.exception; + +import lombok.Getter; + +public class BusinessLogicException extends RuntimeException{ + @Getter + private ExceptionCode exceptionCode; + + public BusinessLogicException(ExceptionCode exceptionCode){ + super(exceptionCode.getMessage()); + this.exceptionCode = exceptionCode; + } +} diff --git a/src/main/java/com/codestates/stackoverflow/exception/ExceptionCode.java b/src/main/java/com/codestates/stackoverflow/exception/ExceptionCode.java new file mode 100644 index 000000000..53742ce8b --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/exception/ExceptionCode.java @@ -0,0 +1,25 @@ +package com.codestates.stackoverflow.exception; + +import lombok.Getter; + +public enum ExceptionCode { + + MEMBER_NOT_FOUND(404, "Member not found."), + TODO_NOT_FOUND(404, "Question not found."), + + MEMBER_EXISTS(409, "Member already exists"), + TODO_EXSITS(409,"Question already exists."); + + + + @Getter + private int status; + + @Getter + private String message; + + ExceptionCode(int status, String message) { + this.status = status; + this.message = message; + } +} diff --git a/src/main/java/com/codestates/stackoverflow/member/controller/MemberController.java b/src/main/java/com/codestates/stackoverflow/member/controller/MemberController.java new file mode 100644 index 000000000..564f30030 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/member/controller/MemberController.java @@ -0,0 +1,100 @@ +package com.codestates.stackoverflow.member.controller; + +import com.codestates.stackoverflow.dto.MultiResponseDto; +import com.codestates.stackoverflow.dto.SingleResponseDto; +import com.codestates.stackoverflow.member.dto.MemberDto; +import com.codestates.stackoverflow.member.entity.Member; +import com.codestates.stackoverflow.member.mapper.MemberMapper; +import com.codestates.stackoverflow.member.service.MemberService; +import com.codestates.stackoverflow.utils.UriCreator; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import javax.validation.constraints.Positive; +import java.net.URI; +import java.util.List; + + +/** + * - DI 적용 + * - Mapstruct Mapper 적용 + * - @ExceptionHandler 적용 + */ +@RestController +@RequestMapping("/users") +@Validated +@Slf4j +public class MemberController { + private final static String MEMBER_DEFAULT_URL = "/users"; + private final MemberService memberService; + private final MemberMapper mapper; + + public MemberController(MemberService memberService, MemberMapper mapper) { + this.memberService = memberService; + this.mapper = mapper; + } + + @PostMapping("/signup") + public ResponseEntity postMember(@Valid @RequestBody MemberDto.Post requestBody) { + Member member = mapper.memberPostToMember(requestBody); + + Member createdMember = memberService.createMember(member); + URI location = UriCreator.createUri(MEMBER_DEFAULT_URL, createdMember.getMemberId()); + + return ResponseEntity.created(location).build(); + } + + @PatchMapping("/{member-id}") + public ResponseEntity patchMember( + @PathVariable("member-id") @Positive long memberId, + @Valid @RequestBody MemberDto.Patch requestBody) { + requestBody.setMemberId(memberId); + + Member member = + memberService.updateMember(mapper.memberPatchToMember(requestBody)); + + return new ResponseEntity<>( + new SingleResponseDto<>(mapper.memberToMemberResponse(member)), + HttpStatus.OK); + } + + @GetMapping("/{member-id}") + public ResponseEntity getMember( + @PathVariable("member-id") @Positive long memberId) { + Member member = memberService.findMember(memberId); + return new ResponseEntity<>( + new SingleResponseDto<>(mapper.memberToMemberResponse(member)) + , HttpStatus.OK); + } + + @GetMapping + public ResponseEntity getMembers(@Positive @RequestParam int page, + @Positive @RequestParam int size) { + Page pageMembers = memberService.findMembers(page - 1, size); + List members = pageMembers.getContent(); + return new ResponseEntity<>( + new MultiResponseDto<>(mapper.membersToMemberResponses(members), + pageMembers), + HttpStatus.OK); + } + + @DeleteMapping("/{member-id}") + public ResponseEntity deleteMember( + @PathVariable("member-id") @Positive long memberId) { + memberService.deleteMember(memberId); + + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + @DeleteMapping + public ResponseEntity deleteMembers(){ + memberService.deleteAll(); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + + } +} diff --git a/src/main/java/com/codestates/stackoverflow/member/dto/MemberDto.java b/src/main/java/com/codestates/stackoverflow/member/dto/MemberDto.java new file mode 100644 index 000000000..2746b45dd --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/member/dto/MemberDto.java @@ -0,0 +1,61 @@ +package com.codestates.stackoverflow.member.dto; + +import com.codestates.stackoverflow.validator.NotSpace; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import java.time.LocalDateTime; + +public class MemberDto { + @Getter + @AllArgsConstructor + public static class Post { + @NotBlank + @Email + private String email; + + @NotBlank + private String password; + + @NotBlank(message = "이름은 공백이 아니어야 합니다.") + private String name; + + } + + @Getter + @AllArgsConstructor + public static class Patch { + private long memberId; + + @NotSpace(message = "회원 이름은 공백이 아니어야 합니다") + private String name; + + //패스워드도 수정가능하도록 함. + @NotSpace(message = "패스워드는 공백이 아니어야 합니다.") + private String password; + + public Patch() { + + } + + public Patch(String name, String password){ + this.name = name; + this.password = password; + } + + public void setMemberId(long memberId) { + this.memberId = memberId; + } + } + + @AllArgsConstructor + @Getter + public static class Response { + private long memberId; + private String email; + private String name; + + } +} diff --git a/src/main/java/com/codestates/stackoverflow/member/entity/Member.java b/src/main/java/com/codestates/stackoverflow/member/entity/Member.java new file mode 100644 index 000000000..68f93a40a --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/member/entity/Member.java @@ -0,0 +1,61 @@ +package com.codestates.stackoverflow.member.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + + +import javax.persistence.*; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@NoArgsConstructor +@Getter +@Setter +@Entity +public class Member { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long memberId; + + @Column(nullable = false, updatable = false, unique = true) + private String email; + + // 추가 + @Column(length = 100, nullable = false) + private String password; + + @Column(length = 100, nullable = false) + private String name; + + @Column(updatable = false, nullable = false, columnDefinition = "DATETIME(0)") + private LocalDateTime createdAt = LocalDateTime.now(); + + + @Column(nullable = false, columnDefinition = "DATETIME(0)") + private LocalDateTime modifiedAt = LocalDateTime.now(); + + //Question과의 의존관계 설정 + + + // 추가 + @ElementCollection(fetch = FetchType.EAGER) + private List roles = new ArrayList<>(); + + public Member(String email) { + this.email = email; + } + + public Member(String email, String name) { + this.email = email; + this.name = name; + + } + + public Member(String email, String password, String name) { + this.email = email; + this.password = password; + this.name = name; + } +} diff --git a/src/main/java/com/codestates/stackoverflow/member/mapper/MemberMapper.java b/src/main/java/com/codestates/stackoverflow/member/mapper/MemberMapper.java new file mode 100644 index 000000000..ea2b5830e --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/member/mapper/MemberMapper.java @@ -0,0 +1,16 @@ +package com.codestates.stackoverflow.member.mapper; + +import com.codestates.stackoverflow.member.dto.MemberDto; +import com.codestates.stackoverflow.member.entity.Member; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +import java.util.List; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface MemberMapper { + Member memberPostToMember(MemberDto.Post requestBody); + Member memberPatchToMember(MemberDto.Patch requestBody); + MemberDto.Response memberToMemberResponse(Member member); + List membersToMemberResponses(List members); +} diff --git a/src/main/java/com/codestates/stackoverflow/member/repository/MemberRepository.java b/src/main/java/com/codestates/stackoverflow/member/repository/MemberRepository.java new file mode 100644 index 000000000..4d1a46e66 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/member/repository/MemberRepository.java @@ -0,0 +1,11 @@ +package com.codestates.stackoverflow.member.repository; + + +import com.codestates.stackoverflow.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/src/main/java/com/codestates/stackoverflow/member/service/MemberService.java b/src/main/java/com/codestates/stackoverflow/member/service/MemberService.java new file mode 100644 index 000000000..af1cc450c --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/member/service/MemberService.java @@ -0,0 +1,133 @@ +package com.codestates.stackoverflow.member.service; + +import com.codestates.stackoverflow.auth.utils.CustomAuthorityUtils; +import com.codestates.stackoverflow.exception.BusinessLogicException; +import com.codestates.stackoverflow.exception.ExceptionCode; +import com.codestates.stackoverflow.member.entity.Member; +import com.codestates.stackoverflow.member.repository.MemberRepository; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * - 메서드 구현 + * - DI 적용 + * - Spring Data JPA 적용 + * - 트랜잭션 적용 + */ +@Transactional +@Service +public class MemberService { + private final MemberRepository memberRepository; + private final ApplicationEventPublisher publisher; + + // 추가 + private final PasswordEncoder passwordEncoder; + private final CustomAuthorityUtils authorityUtils; + + public MemberService(MemberRepository memberRepository, + ApplicationEventPublisher publisher, + PasswordEncoder passwordEncoder, + CustomAuthorityUtils authorityUtils) { + this.memberRepository = memberRepository; + this.publisher = publisher; + this.passwordEncoder = passwordEncoder; + this.authorityUtils = authorityUtils; + } + + public Member createMember(Member member) { + verifyExistsEmail(member.getEmail()); + + // 추가: Password 암호화 + String encryptedPassword = passwordEncoder.encode(member.getPassword()); + member.setPassword(encryptedPassword); + + // 추가: DB에 User Role 저장 + List roles = authorityUtils.createRoles(member.getEmail()); + member.setRoles(roles); + + Member savedMember = memberRepository.save(member); + + + //publisher.publishEvent(new MemberRegistrationApplicationEvent(savedMember)); + return savedMember; + } + + @Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.SERIALIZABLE) + public Member updateMember(Member member) { + Member findMember = findVerifiedMember(member.getMemberId()); + + Optional.ofNullable(member.getName()) + .ifPresent(name -> findMember.setName(name)); + + Optional.ofNullable(passwordEncoder.encode(member.getPassword())) + .ifPresent(password -> findMember.setPassword(password)); + + //수정 시간 업데이트 + findMember.setModifiedAt(LocalDateTime.now()); + + return memberRepository.save(findMember); + } + + @Transactional(readOnly = true) + public Member findMember(long memberId) { + return findVerifiedMember(memberId); + } + + @Transactional(readOnly = true) + public Member findMember(String email){ + return findVerifiedMember(email); + } + + public Page findMembers(int page, int size) { + return memberRepository.findAll(PageRequest.of(page, size, + Sort.by("memberId").descending())); + } + + public void deleteMember(long memberId) { + Member findMember = findVerifiedMember(memberId); + + memberRepository.delete(findMember); + } + + public void deleteAll() { + memberRepository.deleteAll(); + } + + @Transactional(readOnly = true) + public Member findVerifiedMember(long memberId) { + Optional optionalMember = + memberRepository.findById(memberId); + Member findMember = + optionalMember.orElseThrow(() -> + new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND)); + return findMember; + } + + public Member findVerifiedMember(String email){ + Optional optionalMember = + memberRepository.findByEmail(email); + Member findMember = + optionalMember.orElseThrow(() -> + new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND)); + return findMember; + } + + private void verifyExistsEmail(String email) { + Optional member = memberRepository.findByEmail(email); + if (member.isPresent()) + throw new BusinessLogicException(ExceptionCode.MEMBER_EXISTS); + } + + +} diff --git a/src/main/java/com/codestates/stackoverflow/response/ErrorResponse.java b/src/main/java/com/codestates/stackoverflow/response/ErrorResponse.java new file mode 100644 index 000000000..7270d5760 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/response/ErrorResponse.java @@ -0,0 +1,99 @@ +package com.codestates.stackoverflow.response; + +import com.codestates.stackoverflow.exception.ExceptionCode; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindingResult; + +import javax.validation.ConstraintViolation; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Getter +public class ErrorResponse { + private int status; + private String message; + private List fieldErrors; + private List violationErrors; + + private ErrorResponse(int status, String message) { + this.status = status; + this.message = message; + } + + private ErrorResponse(final List fieldErrors, + final List violationErrors) { + this.fieldErrors = fieldErrors; + this.violationErrors = violationErrors; + } + + public static ErrorResponse of(BindingResult bindingResult) { + return new ErrorResponse(FieldError.of(bindingResult), null); + } + + public static ErrorResponse of(Set> violations) { + return new ErrorResponse(null, ConstraintViolationError.of(violations)); + } + + public static ErrorResponse of(ExceptionCode exceptionCode) { + return new ErrorResponse(exceptionCode.getStatus(), exceptionCode.getMessage()); + } + + public static ErrorResponse of(HttpStatus httpStatus) { + return new ErrorResponse(httpStatus.value(), httpStatus.getReasonPhrase()); + } + + public static ErrorResponse of(HttpStatus httpStatus, String message) { + return new ErrorResponse(httpStatus.value(), message); + } + + @Getter + public static class FieldError { + private String field; + private Object rejectedValue; + private String reason; + + private FieldError(String field, Object rejectedValue, String reason) { + this.field = field; + this.rejectedValue = rejectedValue; + this.reason = reason; + } + + public static List of(BindingResult bindingResult) { + final List fieldErrors = + bindingResult.getFieldErrors(); + return fieldErrors.stream() + .map(error -> new FieldError( + error.getField(), + error.getRejectedValue() == null ? + "" : error.getRejectedValue().toString(), + error.getDefaultMessage())) + .collect(Collectors.toList()); + } + } + + @Getter + public static class ConstraintViolationError { + private String propertyPath; + private Object rejectedValue; + private String reason; + + private ConstraintViolationError(String propertyPath, Object rejectedValue, + String reason) { + this.propertyPath = propertyPath; + this.rejectedValue = rejectedValue; + this.reason = reason; + } + + public static List of( + Set> constraintViolations) { + return constraintViolations.stream() + .map(constraintViolation -> new ConstraintViolationError( + constraintViolation.getPropertyPath().toString(), + constraintViolation.getInvalidValue().toString(), + constraintViolation.getMessage() + )).collect(Collectors.toList()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/codestates/stackoverflow/util/ApiDocumentUtils.java b/src/main/java/com/codestates/stackoverflow/util/ApiDocumentUtils.java new file mode 100644 index 000000000..2f3d25bd9 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/util/ApiDocumentUtils.java @@ -0,0 +1,16 @@ +package com.codestates.stackoverflow.util; + +import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor; +import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor; + +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; + +public interface ApiDocumentUtils { + static OperationRequestPreprocessor getRequestPreProcessor(){ + return preprocessRequest(prettyPrint()); + } + + static OperationResponsePreprocessor getResponsePreProcessor(){ + return preprocessResponse(prettyPrint()); + } +} diff --git a/src/main/java/com/codestates/stackoverflow/utils/UriCreator.java b/src/main/java/com/codestates/stackoverflow/utils/UriCreator.java new file mode 100644 index 000000000..b82adfd50 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/utils/UriCreator.java @@ -0,0 +1,16 @@ +package com.codestates.stackoverflow.utils; + +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +public class UriCreator { + public static URI createUri(String defaultUrl, long resourceId){ + return UriComponentsBuilder + .newInstance() + .path(defaultUrl + "/{resource-id}") + .buildAndExpand(resourceId) + .toUri(); + } + +} diff --git a/src/main/java/com/codestates/stackoverflow/validator/NotSpace.java b/src/main/java/com/codestates/stackoverflow/validator/NotSpace.java new file mode 100644 index 000000000..74c9602a9 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/validator/NotSpace.java @@ -0,0 +1,17 @@ +package com.codestates.stackoverflow.validator; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = {NotSpaceValidator.class}) +public @interface NotSpace { + String message() default "공백이 아니어야 합니다"; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/codestates/stackoverflow/validator/NotSpaceValidator.java b/src/main/java/com/codestates/stackoverflow/validator/NotSpaceValidator.java new file mode 100644 index 000000000..48b25e1d3 --- /dev/null +++ b/src/main/java/com/codestates/stackoverflow/validator/NotSpaceValidator.java @@ -0,0 +1,19 @@ +package com.codestates.stackoverflow.validator; + +import org.springframework.util.StringUtils; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class NotSpaceValidator implements ConstraintValidator { + + @Override + public void initialize(NotSpace constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return value == null || StringUtils.hasText(value); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 000000000..11a7fca45 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,54 @@ +spring: + h2: + console: + enabled: true + path: /h2 + datasource: + url: jdbc:h2:mem:test + jpa: + hibernate: + ddl-auto: create # (1) 스키마 자동 생성 + show-sql: true # (2) SQL 쿼리 출력 + properties: + hibernate: + format_sql: true + highlight_sql: true + color-codes: true + use_sql_comments: true + defer-datasource-initialization: true # 데이터소스 초기화 지연 설정. JPA의 초기화 설정을 마칠때까지 데이터소스 사용을 지연시킨다. + # open-in-view: true + security: #JWT와 Oauth2를 함께 사용하기 위한 설정 + oauth2: + client: + registration: + google: + clientId: ${G_CLIENT_ID} + clientSecret: ${G_CLIENT_SECRET} + scope: + ["email", "profile"] + + output: + ansi: + enabled: ALWAYS +logging: + level: + org: + hibernate: + # SQL: debug # jpa 설정에서 쿼리를 보여주므로 필요 없음. + type: + descriptor: + sql: + BasicBinder: trace +server: + servlet: + encoding: + force-response: true + +mail: + address: + admin: admin@gmail.com + +jwt: + key: ${JWT_SECRET_KEY} + access-token-expiration-minutes: 30 + refresh-token-expiration-minutes: 420 diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html new file mode 100644 index 000000000..47494a7e1 --- /dev/null +++ b/src/main/resources/static/docs/index.html @@ -0,0 +1,861 @@ + + + + + + + +Stackoverflow Application + + + + + +
+
+
+
+

Writer : Park Jeein

+
+
+

v1.0.0. 2023.06.14

+
+
+
+
+
+

1. MemberController

+
+
+

1.1. 회원 등록

+
+
http-request
+
+
POST /users/signup HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Accept: application/json
+Content-Length: 81
+Host: localhost:8080
+
+{
+  "email" : "jeein@gmail.com",
+  "password" : "1234",
+  "name" : "jeein Park"
+}
+
+
+ + +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
Table 1. request-fields
PathTypeDescription

email

String

이메일

password

String

패스워드

name

String

이름

+
+
http-response
+
+
HTTP/1.1 201 Created
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Location: /users/1
+X-Content-Type-Options: nosniff
+X-XSS-Protection: 1; mode=block
+Cache-Control: no-cache, no-store, max-age=0, must-revalidate
+Pragma: no-cache
+Expires: 0
+X-Frame-Options: SAMEORIGIN
+
+
+ + ++++ + + + + + + + + + + + + +
Table 2. response-headers
NameDescription

Location

Location header. 등록된 리소스의 URI

+
+
+

1.2. 회원 정보 수정

+
+
http-request
+
+
PATCH /users/1 HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Accept: application/json
+Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJVU0VSIl0sInVzZ~
+Content-Length: 68
+Host: localhost:8080
+
+{
+  "memberId" : 1,
+  "name" : "jeein Park",
+  "password" : "1234"
+}
+
+
+ + ++++ + + + + + + + + + + + + +
Table 3. /users/{member-id}
ParameterDescription

member-id

회원 식별자 ID

+ + +++++ + + + + + + + + + + + + + + + + + + + +
Table 4. request-fields
PathTypeDescription

name

String

이름

password

String

패스워드

+
+
http-response
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+X-Content-Type-Options: nosniff
+X-XSS-Protection: 1; mode=block
+Cache-Control: no-cache, no-store, max-age=0, must-revalidate
+Pragma: no-cache
+Expires: 0
+X-Frame-Options: SAMEORIGIN
+Content-Length: 99
+
+{
+  "data" : {
+    "memberId" : 1,
+    "email" : "jeein@gmail.com",
+    "name" : "jeein Park"
+  }
+}
+
+
+
+
+

1.3. 회원 정보 조회

+
+
http-request
+
+
GET /users/1 HTTP/1.1
+Accept: application/json
+Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJVU0VSIl0sInVzZ~
+Host: localhost:8080
+
+
+ + ++++ + + + + + + + + + + + + +
Table 5. /users/{member-id}
ParameterDescription

member-id

회원 식별자 ID

+
+
http-response
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+X-Content-Type-Options: nosniff
+X-XSS-Protection: 1; mode=block
+Cache-Control: no-cache, no-store, max-age=0, must-revalidate
+Pragma: no-cache
+Expires: 0
+X-Frame-Options: SAMEORIGIN
+Content-Length: 99
+
+{
+  "data" : {
+    "memberId" : 1,
+    "email" : "jeein@gmail.com",
+    "name" : "jeein Park"
+  }
+}
+
+
+
+
+

1.4. 전체 회원 정보 조회

+
+
http-request
+
+
GET /users?page=1&size=10 HTTP/1.1
+Accept: application/json
+Host: localhost:8080
+
+
+ + ++++ + + + + + + + + + + + + + + + + +
Table 6. request-parameters
ParameterDescription

page

Page 번호

size

Page Size

+
+
http-response
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json;charset=UTF-8
+X-Content-Type-Options: nosniff
+X-XSS-Protection: 1; mode=block
+Cache-Control: no-cache, no-store, max-age=0, must-revalidate
+Pragma: no-cache
+Expires: 0
+X-Frame-Options: SAMEORIGIN
+Content-Length: 294
+
+{
+  "data" : [ {
+    "memberId" : 1,
+    "email" : "jeein1@gmail.com",
+    "name" : "Jeein Park1"
+  }, {
+    "memberId" : 2,
+    "email" : "jeein2@gmail.com",
+    "name" : "Jeein Park2"
+  } ],
+  "pageInfo" : {
+    "page" : 1,
+    "size" : 10,
+    "totalElements" : 2,
+    "totalPages" : 1
+  }
+}
+
+
+
+
+

1.5. 회원 탈퇴

+
+
http-request
+
+
DELETE /users/1 HTTP/1.1
+Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJVU0VSIl0sInVzZ~
+Host: localhost:8080
+
+
+ + ++++ + + + + + + + + + + + + +
Table 7. /users/{member-id}
ParameterDescription

member-id

회원 식별자 ID

+
+
http-response
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+X-Content-Type-Options: nosniff
+X-XSS-Protection: 1; mode=block
+Cache-Control: no-cache, no-store, max-age=0, must-revalidate
+Pragma: no-cache
+Expires: 0
+X-Frame-Options: SAMEORIGIN
+
+
+
+
+

1.6. 전체 회원 삭제

+
+
http-request
+
+
DELETE /users HTTP/1.1
+Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJVU0VSIl0sInVzZ~
+Host: localhost:8080
+
+
+
+
http-response
+
+
HTTP/1.1 204 No Content
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+X-Content-Type-Options: nosniff
+X-XSS-Protection: 1; mode=block
+Cache-Control: no-cache, no-store, max-age=0, must-revalidate
+Pragma: no-cache
+Expires: 0
+X-Frame-Options: SAMEORIGIN
+
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/test/java/com/codestates/stackoverflow/StackoverflowApplicationTests.java b/src/test/java/com/codestates/stackoverflow/StackoverflowApplicationTests.java new file mode 100644 index 000000000..193fd1a87 --- /dev/null +++ b/src/test/java/com/codestates/stackoverflow/StackoverflowApplicationTests.java @@ -0,0 +1,13 @@ +package com.codestates.stackoverflow; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class StackoverflowApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/com/codestates/stackoverflow/slice/mock/MemberControllerDocumentationTest.java b/src/test/java/com/codestates/stackoverflow/slice/mock/MemberControllerDocumentationTest.java new file mode 100644 index 000000000..a5420ba83 --- /dev/null +++ b/src/test/java/com/codestates/stackoverflow/slice/mock/MemberControllerDocumentationTest.java @@ -0,0 +1,370 @@ +package com.codestates.stackoverflow.slice.mock; + +import com.codestates.stackoverflow.member.dto.MemberDto; +import com.codestates.stackoverflow.member.entity.Member; +import com.codestates.stackoverflow.member.mapper.MemberMapper; +import com.codestates.stackoverflow.member.service.MemberService; +import com.google.gson.Gson; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; + +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.constraints.ConstraintDescriptions; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; + +import static com.codestates.stackoverflow.util.ApiDocumentUtils.getRequestPreProcessor; +import static com.codestates.stackoverflow.util.ApiDocumentUtils.getResponsePreProcessor; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.restdocs.snippet.Attributes.key; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; + + + +@SpringBootTest +//@WebMvcTest(MemberController.class) -> Spring Security를 활설화시키지 않음. +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureMockMvc +@AutoConfigureRestDocs +class MemberControllerDocumentationTest { + @Autowired + private MockMvc mockMvc; + + @Autowired + private Gson gson; + + + @MockBean + private MemberService memberService; + + + @MockBean + private MemberMapper mapper; + + @Test + @PreAuthorize("hasAnyRole('USER', 'ADMIN')") + void postMemberTest() throws Exception { + // given + MemberDto.Post post = new MemberDto.Post("jeein@gmail.com", "1234", "jeein Park"); + String content = gson.toJson(post); + + given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class))).willReturn(new Member()); +// + Member mockResultMember= new Member(); + mockResultMember.setMemberId(1L); + + given(memberService.createMember(Mockito.any(Member.class))).willReturn(mockResultMember); + + + URI uri = UriComponentsBuilder.newInstance().path("/users/signup").build().toUri(); + + // when + ResultActions actions = + mockMvc.perform( + post(uri) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(content)); + + + // then + actions + .andExpect(status().isCreated()) + .andExpect(header().string("Location", is(startsWith("/users/")))) + .andDo(document( + "post-member", + getRequestPreProcessor(), + getResponsePreProcessor(), + requestFields( + List.of( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("password").type(JsonFieldType.STRING).description("패스워드"), + fieldWithPath("name").type(JsonFieldType.STRING).description("이름") + ) + ), + responseHeaders( + headerWithName(HttpHeaders.LOCATION).description("Location header. 등록된 리소스의 URI") + ) + )); + + } + + @Test + @WithMockUser(username = "user@gmail.com",password="1234",roles="USER") + void patchMemberTest() throws Exception{ + //given + long memberId = 1L; + + MemberDto.Patch patch = new MemberDto.Patch( "jeein Park", "1234"); + patch.setMemberId(1L); + + + MemberDto.Response response = new MemberDto.Response(1L, + "jeein@gmail.com", + "jeein Park"); + + given(mapper.memberPatchToMember(Mockito.any(MemberDto.Patch.class))).willReturn(new Member()); + + given(memberService.updateMember(Mockito.any(Member.class))).willReturn(new Member()); + + given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(response); + + String content = gson.toJson(patch); + + + //when + ResultActions actions = + mockMvc.perform( + patch("/users/{member-id}",memberId) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(content) + ); + + + + //유효성 검증에 사용된 애너테이션에 대한 정보를 추가 + ConstraintDescriptions patchMemberConstraints = new ConstraintDescriptions(MemberDto.Patch.class); // 유효성 검증 조건 정보 객체 생성 + + List nameDescriptions=Arrays.asList("이름은 공백이 아니어야 합니다"); + List passwordDescriptions=Arrays.asList("패스워드는 공백이 아니어야 합니다"); + + + + //then + actions.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberId").value(patch.getMemberId())) + .andExpect(jsonPath("$.data.name").value(patch.getName())) + .andDo(document("patch-member", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + List.of(parameterWithName("member-id").description("회원 식별자 ID")) + ), + requestFields( + List.of( + fieldWithPath("memberId").type(JsonFieldType.NUMBER).description("회원 식별자").ignored(), + fieldWithPath("name").type(JsonFieldType.STRING).description("이름") + .attributes(key("constraints").value(nameDescriptions)), + fieldWithPath("password").type(JsonFieldType.STRING).description("패스워드") + .attributes(key("constraints").value(passwordDescriptions)) + ) + ), + responseFields( + List.of( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터").optional(), + fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"), + fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("이름") + ) + ) + )); + + + } + + @Test + @WithMockUser(username = "user@gmail.com",password="1234",roles="USER") + void getMemberTest() throws Exception { + + //given + long memberId = 1L; + Member member = new Member("jeein@gmail.com", "1234", "jeein Park"); + member.setMemberId(memberId); + + MemberDto.Response response = new MemberDto.Response(1L, + "jeein@gmail.com", + "jeein Park"); + + given(memberService.findMember(Mockito.anyLong())).willReturn(new Member()); + given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(response); + + + //when + ResultActions actions = mockMvc.perform( + get("/users/{member-id}",memberId) + .accept(MediaType.APPLICATION_JSON) + ); + + //then + actions.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.email").value(member.getEmail())) + .andExpect(jsonPath("$.data.name").value(member.getName())) + .andDo( + document("get-member", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + List.of(parameterWithName("member-id").description("회원 식별자 ID")) + ), + responseFields( + List.of( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터").optional(), + fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"), + fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("이름") + ) + ) + + )); + } + + @Test + @WithMockUser(username = "admin@gmail.com",password="1234",roles="ADMIN") + void getMembersTest() throws Exception{ + //given + Member member1 = new Member("jeein1@gmail.com", "jeein Park1"); + Member member2 = new Member("jeein2@gmail.com", "jeein Park2"); + + Page pageMembers = new PageImpl<>( + List.of(member1, member2), + PageRequest.of(0, 10, + Sort.by("memberId").descending()),2 + ); + + List responses = List.of( + new MemberDto.Response(1L, + "jeein1@gmail.com", + "Jeein Park1"), + new MemberDto.Response(2L, + "jeein2@gmail.com", + "Jeein Park2") + ); + + given(memberService.findMembers(Mockito.anyInt(), Mockito.anyInt())).willReturn(pageMembers); + given(mapper.membersToMemberResponses(Mockito.anyList())).willReturn(responses); + + String page = "1"; + String size = "10"; + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("page", page); + queryParams.add("size", size); + + URI uri = UriComponentsBuilder.newInstance().path("/users").build().toUri(); + + //when + ResultActions actions = mockMvc.perform( + get(uri) + .params(queryParams) + .accept(MediaType.APPLICATION_JSON) + ); + + //then + MvcResult result = actions + .andExpect(status().isOk()) + .andDo( + document( + "get-members", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestParameters( + List.of( + parameterWithName("page").description("Page 번호"), + parameterWithName("size").description("Page Size") + ) + ), + responseFields( + List.of( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("결과 데이터").optional(), + fieldWithPath("data[].memberId").type(JsonFieldType.NUMBER).description("회원 식별자"), + fieldWithPath("data[].email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("이름"), + fieldWithPath("pageInfo").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageInfo.page").type(JsonFieldType.NUMBER).description("페이지 번호"), + fieldWithPath("pageInfo.size").type(JsonFieldType.NUMBER).description("페이지 사이즈"), + fieldWithPath("pageInfo.totalElements").type(JsonFieldType.NUMBER).description("전체 건 수"), + fieldWithPath("pageInfo.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수") + ) + + ) + ) + + ) + .andReturn(); + + List list = JsonPath.parse(result.getResponse().getContentAsString()).read("$.data"); + + assertThat(list.size(), is(2)); + } + + @Test + @WithMockUser(username = "user@gmail.com",password="1234",roles="USER") + void deleteMemberTest() throws Exception{ + //given + long memberId = 1L; + + doNothing().when(memberService).deleteMember(memberId); + + //when + ResultActions actions = mockMvc.perform(delete("/users/{member-id}",memberId)); + + //then + actions.andExpect(status().isNoContent()) + .andDo( + document( + "delete-member", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + Arrays.asList(parameterWithName("member-id").description("회원 식별자 ID")) + ) + ) + ); + } + + @Test + @WithMockUser(username = "user@gmail.com",password="1234",roles="ADMIN") + void deleteMembersTest() throws Exception{ + + //given + doNothing().when(memberService).deleteAll(); + + //when + ResultActions actions = mockMvc.perform(delete("/users")); + + //then + actions.andExpect(status().isNoContent()) + .andDo( + document( + "delete-members", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()) + ) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/codestates/stackoverflow/slice/mock/MemberControllerDocumentationTestWithJwt.java b/src/test/java/com/codestates/stackoverflow/slice/mock/MemberControllerDocumentationTestWithJwt.java new file mode 100644 index 000000000..562f6ddcc --- /dev/null +++ b/src/test/java/com/codestates/stackoverflow/slice/mock/MemberControllerDocumentationTestWithJwt.java @@ -0,0 +1,401 @@ +package com.codestates.stackoverflow.slice.mock; + +import com.codestates.stackoverflow.member.dto.MemberDto; +import com.codestates.stackoverflow.member.entity.Member; +import com.codestates.stackoverflow.member.mapper.MemberMapper; +import com.codestates.stackoverflow.member.service.MemberService; +import com.google.gson.Gson; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.restdocs.constraints.ConstraintDescriptions; +import org.springframework.restdocs.headers.HeaderDocumentation; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; + +import static com.codestates.stackoverflow.util.ApiDocumentUtils.getRequestPreProcessor; +import static com.codestates.stackoverflow.util.ApiDocumentUtils.getResponsePreProcessor; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.springframework.restdocs.headers.HeaderDocumentation.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.restdocs.snippet.Attributes.key; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + + +@SpringBootTest +//@WebMvcTest(MemberController.class) -> Spring Security를 활설화시키지 않음. +@MockBean(JpaMetamodelMappingContext.class) +@AutoConfigureMockMvc +@AutoConfigureRestDocs +class MemberControllerDocumentationTestWithJwt { + @Autowired + private MockMvc mockMvc; + + @Autowired + private Gson gson; + + @MockBean + private MemberService memberService; + + @MockBean + private MemberMapper mapper; + + @Test + @PreAuthorize("hasAnyRole('USER', 'ADMIN')") + void postMemberTest() throws Exception { + // given + MemberDto.Post post = new MemberDto.Post("jeein@gmail.com", "1234", "jeein Park"); + String content = gson.toJson(post); + + given(mapper.memberPostToMember(Mockito.any(MemberDto.Post.class))).willReturn(new Member()); + + Member mockResultMember= new Member(); + mockResultMember.setMemberId(1L); + + given(memberService.createMember(Mockito.any(Member.class))).willReturn(mockResultMember); + + + URI uri = UriComponentsBuilder.newInstance().path("/users/signup").build().toUri(); + + // when + ResultActions actions = + mockMvc.perform( + post(uri) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content(content)); + + + // then + actions + .andExpect(status().isCreated()) + .andExpect(header().string("Location", is(startsWith("/users/")))) + .andDo(document( + "post-member", + getRequestPreProcessor(), + getResponsePreProcessor(), + requestFields( + List.of( + fieldWithPath("email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("password").type(JsonFieldType.STRING).description("패스워드"), + fieldWithPath("name").type(JsonFieldType.STRING).description("이름") + ) + ), + responseHeaders( + headerWithName(HttpHeaders.LOCATION).description("Location header. 등록된 리소스의 URI") + ) + )); + + } + + @Test + @WithMockUser(username = "user@gmail.com",password="1234",roles="USER") + void patchMemberTest() throws Exception{ + //given + long memberId = 1L; + + MemberDto.Patch patch = new MemberDto.Patch( "jeein Park", "1234"); + patch.setMemberId(1L); + + + MemberDto.Response response = new MemberDto.Response(1L, + "jeein@gmail.com", + "jeein Park"); + + given(mapper.memberPatchToMember(Mockito.any(MemberDto.Patch.class))).willReturn(new Member()); + + given(memberService.updateMember(Mockito.any(Member.class))).willReturn(new Member()); + + given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(response); + + String content = gson.toJson(patch); + + //JWT Authorization api 추가 + String jwtToken = "eyJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJVU0VSIl0sInVzZ~"; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + jwtToken); + + + //when + ResultActions actions = + mockMvc.perform( + patch("/users/{member-id}",memberId) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .headers(headers) + .content(content) + ); + + + + //유효성 검증에 사용된 애너테이션에 대한 정보를 추가 + ConstraintDescriptions patchMemberConstraints = new ConstraintDescriptions(MemberDto.Patch.class); // 유효성 검증 조건 정보 객체 생성 + + List nameDescriptions=Arrays.asList("이름은 공백이 아니어야 합니다"); + List passwordDescriptions=Arrays.asList("패스워드는 공백이 아니어야 합니다"); + + + //then + actions.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.memberId").value(patch.getMemberId())) + .andExpect(jsonPath("$.data.name").value(patch.getName())) + .andDo(document("patch-member", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + List.of(parameterWithName("member-id").description("회원 식별자 ID")) + ), + requestFields( + List.of( + fieldWithPath("memberId").type(JsonFieldType.NUMBER).description("회원 식별자").ignored(), + fieldWithPath("name").type(JsonFieldType.STRING).description("이름") + .attributes(key("constraints").value(nameDescriptions)), + fieldWithPath("password").type(JsonFieldType.STRING).description("패스워드") + .attributes(key("constraints").value(passwordDescriptions)) + ) + ), + requestHeaders( + HeaderDocumentation.headerWithName("Authorization").description("JWT Token") + ), + responseFields( + List.of( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터").optional(), + fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"), + fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("이름") + ) + ) + )); + + + } + + @Test + @WithMockUser(username = "user@gmail.com",password="1234",roles="USER") + void getMemberTest() throws Exception { + + //given + long memberId = 1L; + Member member = new Member("jeein@gmail.com", "1234", "jeein Park"); + member.setMemberId(memberId); + + MemberDto.Response response = new MemberDto.Response(1L, + "jeein@gmail.com", + "jeein Park"); + + given(memberService.findMember(Mockito.anyLong())).willReturn(new Member()); + given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(response); + + + + String jwtToken = "eyJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJVU0VSIl0sInVzZ~"; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + jwtToken); + + //when + ResultActions actions = mockMvc.perform( + get("/users/{member-id}",memberId) + .accept(MediaType.APPLICATION_JSON) + .headers(headers) + ); + + //then + actions.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.email").value(member.getEmail())) + .andExpect(jsonPath("$.data.name").value(member.getName())) + .andDo( + document("get-member", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + List.of(parameterWithName("member-id").description("회원 식별자 ID")) + ), + requestHeaders( + HeaderDocumentation.headerWithName("Authorization").description("JWT Token") + ), + responseFields( + List.of( + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터").optional(), + fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"), + fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("이름") + ) + ) + + )); + } + + @Test + void getMembersTest() throws Exception{ + //given + Member member1 = new Member("jeein1@gmail.com", "jeein Park1"); + Member member2 = new Member("jeein2@gmail.com", "jeein Park2"); + + Page pageMembers = new PageImpl<>( + List.of(member1, member2), + PageRequest.of(0, 10, + Sort.by("memberId").descending()),2 + ); + + List responses = List.of( + new MemberDto.Response(1L, + "jeein1@gmail.com", + "Jeein Park1"), + new MemberDto.Response(2L, + "jeein2@gmail.com", + "Jeein Park2") + ); + + given(memberService.findMembers(Mockito.anyInt(), Mockito.anyInt())).willReturn(pageMembers); + given(mapper.membersToMemberResponses(Mockito.anyList())).willReturn(responses); + + String page = "1"; + String size = "10"; + MultiValueMap queryParams = new LinkedMultiValueMap<>(); + queryParams.add("page", page); + queryParams.add("size", size); + + URI uri = UriComponentsBuilder.newInstance().path("/users").build().toUri(); + + + //when + ResultActions actions = mockMvc.perform( + get(uri) + .params(queryParams) + .accept(MediaType.APPLICATION_JSON) + + ); + + //then + MvcResult result = actions + .andExpect(status().isOk()) + .andDo( + document( + "get-members", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestParameters( + List.of( + parameterWithName("page").description("Page 번호"), + parameterWithName("size").description("Page Size") + ) + ), + responseFields( + List.of( + fieldWithPath("data").type(JsonFieldType.ARRAY).description("결과 데이터").optional(), + fieldWithPath("data[].memberId").type(JsonFieldType.NUMBER).description("회원 식별자"), + fieldWithPath("data[].email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("이름"), + fieldWithPath("pageInfo").type(JsonFieldType.OBJECT).description("페이지 정보"), + fieldWithPath("pageInfo.page").type(JsonFieldType.NUMBER).description("페이지 번호"), + fieldWithPath("pageInfo.size").type(JsonFieldType.NUMBER).description("페이지 사이즈"), + fieldWithPath("pageInfo.totalElements").type(JsonFieldType.NUMBER).description("전체 건 수"), + fieldWithPath("pageInfo.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수") + ) + + ) + ) + + ) + .andReturn(); + + List list = JsonPath.parse(result.getResponse().getContentAsString()).read("$.data"); + + assertThat(list.size(), is(2)); + } + + @Test + @WithMockUser(username = "user@gmail.com",password="1234",roles="USER") + void deleteMemberTest() throws Exception{ + //given + long memberId = 1L; + + doNothing().when(memberService).deleteMember(memberId); + + String jwtToken = "eyJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJVU0VSIl0sInVzZ~"; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + jwtToken); + + //when + ResultActions actions = mockMvc.perform(delete("/users/{member-id}",memberId).headers(headers)); + + //then + actions.andExpect(status().isNoContent()) + .andDo( + document( + "delete-member", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + Arrays.asList(parameterWithName("member-id").description("회원 식별자 ID")) + ), + requestHeaders( + HeaderDocumentation.headerWithName("Authorization").description("JWT Token") + ) + ) + ); + } + + @Test + @WithMockUser(username = "user@gmail.com",password="1234",roles="ADMIN") + void deleteMembersTest() throws Exception{ + + //given + doNothing().when(memberService).deleteAll(); + + String jwtToken = "eyJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJVU0VSIl0sInVzZ~"; + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + jwtToken); + + //when + ResultActions actions = mockMvc.perform(delete("/users").headers(headers)); + + //then + actions.andExpect(status().isNoContent()) + .andDo( + document( + "delete-members", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestHeaders( + HeaderDocumentation.headerWithName("Authorization").description("JWT Token") + ) + ) + ); + } +} \ No newline at end of file