commit 0b6b790425ed634f2e0a8ee0a0e255d04be43205 Author: Hyojin Ahn Date: Wed Nov 19 08:33:51 2025 -0500 from auth-service diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..c1dd12f Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..b74bf7f --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.6/apache-maven-3.8.6-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 + + http://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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3e7f79 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Spring Boot 3.0 Security with JWT Implementation +This project demonstrates the implementation of security using Spring Boot 3.0 and JSON Web Tokens (JWT). It includes the following features: + +## Features +* User registration and login with JWT authentication +* Password encryption using BCrypt +* Role-based authorization with Spring Security +* Customized access denied handling +* Logout mechanism +* Refresh token + +## Technologies +* Spring Boot 3.0 +* Spring Security +* JSON Web Tokens (JWT) +* BCrypt +* Maven + +## Getting Started +To get started with this project, you will need to have the following installed on your local machine: + +* JDK 17+ +* Maven 3+ + + +To build and run the project, follow these steps: + +* Clone the repository: `git clone https://github.com/ali-bouali/spring-boot-3-jwt-security.git` +* Navigate to the project directory: cd spring-boot-security-jwt +* Add database "jwt_security" to postgres +* Build the project: mvn clean install +* Run the project: mvn spring-boot:run + +-> The application will be available at http://localhost:8080. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8c2973b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + postgres: + container_name: postgres-sql + image: postgres + environment: + POSTGRES_USER: username + POSTGRES_PASSWORD: password + PGDATA: /data/postgres + volumes: + - postgres:/data/postgres + ports: + - "5432:5432" + networks: + - postgres + restart: unless-stopped + + pgadmin: + container_name: pgadmin + image: dpage/pgadmin4 + environment: + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-pgadmin4@pgadmin.org} + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} + PGADMIN_CONFIG_SERVER_MODE: 'False' + volumes: + - pgadmin:/var/lib/pgadmin + ports: + - "5050:80" + networks: + - postgres + restart: unless-stopped + +networks: + postgres: + driver: bridge + +volumes: + postgres: + pgadmin: \ No newline at end of file diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..8a8fb22 --- /dev/null +++ b/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + 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 + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..1d8ab01 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. 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, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..9f60383 --- /dev/null +++ b/pom.xml @@ -0,0 +1,102 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.1.4 + + + com.goi + auth-service + 0.0.1-SNAPSHOT + security + Demo project for Spring Boot + + 17 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-web + + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + true + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.1.0 + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + template + layered-architecture-template + 1.0.0-SNAPSHOT + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/src/main/java/com/goi/erp/SecurityApplication.java b/src/main/java/com/goi/erp/SecurityApplication.java new file mode 100644 index 0000000..199783c --- /dev/null +++ b/src/main/java/com/goi/erp/SecurityApplication.java @@ -0,0 +1,44 @@ +package com.goi.erp; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@SpringBootApplication +@EnableJpaAuditing(auditorAwareRef = "auditorAware") +@EntityScan(basePackages = {"com.goi.erp"}) +@EnableJpaRepositories(basePackages = {"com.goi.erp"}) +public class SecurityApplication { + + public static void main(String[] args) { + SpringApplication.run(SecurityApplication.class, args); + } + +// @Bean +// public CommandLineRunner commandLineRunner( +// AuthenticationService service +// ) { +// return args -> { +// var admin = RegisterRequest.builder() +// .firstname("Admin") +// .lastname("Admin") +// .email("admin@mail.com") +// .password("password") +// .role(ADMIN) +// .build(); +// System.out.println("Admin token: " + service.register(admin).getAccessToken()); +// +// var manager = RegisterRequest.builder() +// .firstname("Admin") +// .lastname("Admin") +// .email("manager@mail.com") +// .password("password") +// .role(MANAGER) +// .build(); +// System.out.println("Manager token: " + service.register(manager).getAccessToken()); +// +// }; +// } +} diff --git a/src/main/java/com/goi/erp/auditing/ApplicationAuditAware.java b/src/main/java/com/goi/erp/auditing/ApplicationAuditAware.java new file mode 100644 index 0000000..08b167c --- /dev/null +++ b/src/main/java/com/goi/erp/auditing/ApplicationAuditAware.java @@ -0,0 +1,30 @@ +package com.goi.erp.auditing; + +import org.springframework.data.domain.AuditorAware; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import com.goi.erp.employee.EmployeeDetails; + +import java.util.Optional; + +public class ApplicationAuditAware implements AuditorAware { + + @Override + public Optional getCurrentAuditor() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || + !authentication.isAuthenticated() || + authentication instanceof AnonymousAuthenticationToken) { + return Optional.empty(); + } + + // EmployeeDetails로 캐스팅 + EmployeeDetails employeeDetails = (EmployeeDetails) authentication.getPrincipal(); + + // 내부 PK(empId) 반환 + return Optional.ofNullable(employeeDetails.getEmployee().getEmpId()); + } +} diff --git a/src/main/java/com/goi/erp/auth/AuthenticationController.java b/src/main/java/com/goi/erp/auth/AuthenticationController.java new file mode 100644 index 0000000..ab8282f --- /dev/null +++ b/src/main/java/com/goi/erp/auth/AuthenticationController.java @@ -0,0 +1,37 @@ +package com.goi.erp.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthenticationController { + + private final AuthenticationService service; + +// @PostMapping("/register") +// public ResponseEntity register( +// @RequestBody RegisterRequest request +// ) { +// return ResponseEntity.ok(service.register(request)); +// } + @PostMapping("/authenticate") + public ResponseEntity authenticate(@RequestBody AuthenticationRequest request) { + return ResponseEntity.ok(service.authenticate(request)); + } + + @PostMapping("/refresh-token") + public void refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { + service.refreshToken(request, response); + } + +} diff --git a/src/main/java/com/goi/erp/auth/AuthenticationRequest.java b/src/main/java/com/goi/erp/auth/AuthenticationRequest.java new file mode 100644 index 0000000..4b2c587 --- /dev/null +++ b/src/main/java/com/goi/erp/auth/AuthenticationRequest.java @@ -0,0 +1,16 @@ +package com.goi.erp.auth; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AuthenticationRequest { + + private String empLoginId; // 기존 email 대신 + private String empLoginPassword; +} diff --git a/src/main/java/com/goi/erp/auth/AuthenticationResponse.java b/src/main/java/com/goi/erp/auth/AuthenticationResponse.java new file mode 100644 index 0000000..6072fbd --- /dev/null +++ b/src/main/java/com/goi/erp/auth/AuthenticationResponse.java @@ -0,0 +1,19 @@ +package com.goi.erp.auth; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AuthenticationResponse { + + @JsonProperty("access_token") + private String accessToken; + @JsonProperty("refresh_token") + private String refreshToken; +} diff --git a/src/main/java/com/goi/erp/auth/AuthenticationService.java b/src/main/java/com/goi/erp/auth/AuthenticationService.java new file mode 100644 index 0000000..6132dc8 --- /dev/null +++ b/src/main/java/com/goi/erp/auth/AuthenticationService.java @@ -0,0 +1,158 @@ +package com.goi.erp.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.goi.erp.config.JwtService; +import com.goi.erp.token.Token; +import com.goi.erp.token.TokenRepository; +import com.goi.erp.token.TokenType; +import com.goi.erp.employee.Employee; +import com.goi.erp.employee.EmployeeRepository; +import com.goi.erp.employee.EmployeeRole; +import com.goi.erp.employee.EmployeeRoleRepository; +import com.goi.erp.role.RolePermissionRepository; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AuthenticationService { + private final EmployeeRepository employeeRepository; + private final EmployeeRoleRepository employeeRoleRepository; + private final TokenRepository tokenRepository; + private final PasswordEncoder passwordEncoder; + private final RolePermissionRepository rolePermissionRepository; + + private final JwtService jwtService; +// private final AuthenticationManager authenticationManager; + +// public AuthenticationResponse register(RegisterRequest request) { +// var user = User.builder().firstname(request.getFirstname()).lastname(request.getLastname()) +// .email(request.getEmail()).password(passwordEncoder.encode(request.getPassword())) +// .role(request.getRole()).build(); +// var savedUser = repository.save(user); +// var jwtToken = jwtService.generateToken(user); +// var refreshToken = jwtService.generateRefreshToken(user); +// saveUserToken(savedUser, jwtToken); +// return AuthenticationResponse.builder().accessToken(jwtToken).refreshToken(refreshToken).build(); +// } + + // 로그인 처리 + public AuthenticationResponse authenticate(AuthenticationRequest request) { + // 1. Employee 조회 + Employee employee = employeeRepository.findByEmpLoginId(request.getEmpLoginId()) + .orElseThrow(() -> new RuntimeException("Employee not found")); + + // 2. 비밀번호 검증 + if (!passwordEncoder.matches(request.getEmpLoginPassword(), employee.getEmpLoginPassword())) { + throw new RuntimeException("Invalid password"); + } + + // 3. EmployeeRole 조회 → Role 이름 리스트 생성 + List activeRoles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId()); + + List roles = activeRoles.stream() + .map(er -> er.getRoleInfo().getRoleName()) + .collect(Collectors.toList()); + + + // 4. Role → Permission 조회 + List permissions = activeRoles.stream() + .flatMap(er -> rolePermissionRepository.findByRoleId(er.getRoleInfo().getRoleId()).stream()) + .map(rp -> rp.getPermissionInfo().getPermModule() + ":" + rp.getPermissionInfo().getPermAction() + ":" + rp.getPermissionInfo().getPermScope()) + .distinct() + .collect(Collectors.toList()); + + // 5. generate token + String jwtToken = jwtService.generateToken(employee, roles, permissions); + String refreshToken = jwtService.generateRefreshToken(employee, roles, permissions); + + // 기존 토큰 회수 및 새 토큰 저장 + revokeAllEmployeeTokens(employee); + saveEmployeeToken(employee, jwtToken); + + return AuthenticationResponse.builder() + .accessToken(jwtToken) + .refreshToken(refreshToken) + .build(); + } + + // JWT 토큰 저장 + private void saveEmployeeToken(Employee employee, String jwtToken) { + Token token = Token.builder() + .employee(employee) + .token(jwtToken) + .tokenType(TokenType.BEARER) + .expired(false) + .revoked(false) + .build(); + tokenRepository.save(token); + } + + // 기존 토큰 회수 + private void revokeAllEmployeeTokens(Employee employee) { + List validTokens = tokenRepository.findAllValidTokenByEmployee(employee.getEmpId()); + if (validTokens.isEmpty()) return; + + validTokens.forEach(token -> { + token.setExpired(true); + token.setRevoked(true); + }); + + tokenRepository.saveAll(validTokens); + } + + // 리프레시 토큰 처리 + public void refreshToken(HttpServletRequest request, HttpServletResponse response) throws IOException { + final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return; + } + + final String refreshToken = authHeader.substring(7); + String empLoginId = jwtService.extractUsername(refreshToken); + + if (empLoginId != null) { + Employee employee = employeeRepository.findByEmpLoginId(empLoginId) + .orElseThrow(() -> new RuntimeException("Employee not found")); + + if (jwtService.isTokenValid(refreshToken, employee)) { + // 3. EmployeeRole 조회 → Role 이름 리스트 생성 + List activeRoles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId()); + + List roles = activeRoles.stream() + .map(er -> er.getRoleInfo().getRoleName()) + .collect(Collectors.toList()); + + + // 4. Role → Permission 조회 + List permissions = activeRoles.stream() + .flatMap(er -> rolePermissionRepository.findByRoleId(er.getRoleInfo().getRoleId()).stream()) + .map(rp -> rp.getPermissionInfo().getPermModule() + ":" + rp.getPermissionInfo().getPermAction() + ":" + rp.getPermissionInfo().getPermScope()) + .distinct() + .collect(Collectors.toList()); + + // 5. generate token + String accessToken = jwtService.generateToken(employee, roles, permissions); + + revokeAllEmployeeTokens(employee); + saveEmployeeToken(employee, accessToken); + + AuthenticationResponse authResponse = AuthenticationResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + + new ObjectMapper().writeValue(response.getOutputStream(), authResponse); + } + } + } +} diff --git a/src/main/java/com/goi/erp/auth/RegisterRequest.java b/src/main/java/com/goi/erp/auth/RegisterRequest.java new file mode 100644 index 0000000..e964bcf --- /dev/null +++ b/src/main/java/com/goi/erp/auth/RegisterRequest.java @@ -0,0 +1,21 @@ +package com.goi.erp.auth; + +import com.goi.erp.user.Role; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class RegisterRequest { + + private String firstname; + private String lastname; + private String email; + private String password; + private Role role; +} diff --git a/src/main/java/com/goi/erp/config/ApplicationConfig.java b/src/main/java/com/goi/erp/config/ApplicationConfig.java new file mode 100644 index 0000000..f2fdbb3 --- /dev/null +++ b/src/main/java/com/goi/erp/config/ApplicationConfig.java @@ -0,0 +1,83 @@ +package com.goi.erp.config; + +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import com.goi.erp.auditing.ApplicationAuditAware; +import com.goi.erp.employee.Employee; +import com.goi.erp.employee.EmployeeRole; +import com.goi.erp.employee.EmployeeRoleRepository; +import com.goi.erp.employee.EmployeeDetails; +import com.goi.erp.employee.EmployeeRepository; + +@Configuration +@RequiredArgsConstructor +public class ApplicationConfig { + + private final EmployeeRepository employeeRepository; + private final EmployeeRoleRepository employeeRoleRepository; + + @Bean + public UserDetailsService userDetailsService() { + return username -> { + Employee employee = employeeRepository.findByEmpLoginId(username) + .orElseThrow(() -> new UsernameNotFoundException("Employee not found")); + + // EmployeeRole 조회 + List roles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId()); + + // EmployeeDetails 생성 + return new EmployeeDetails(employee, roles); + }; + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService()); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuditorAware auditorAware() { + return new ApplicationAuditAware(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + public static void main(String[] args) { + String rawPassword = "1111"; // 테스트할 비밀번호 + PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + String hashedPassword = passwordEncoder.encode(rawPassword); + System.out.println("Raw password: " + rawPassword); + System.out.println("Hashed password: " + hashedPassword); + + // 확인용 matches 테스트 + boolean matches = passwordEncoder.matches(rawPassword, hashedPassword); + System.out.println("Matches: " + matches); + } + +} diff --git a/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java b/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..244377e --- /dev/null +++ b/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java @@ -0,0 +1,91 @@ +package com.goi.erp.config; + +import com.goi.erp.employee.Employee; +import com.goi.erp.employee.EmployeeDetails; +import com.goi.erp.employee.EmployeeRepository; +import com.goi.erp.employee.EmployeeRole; +import com.goi.erp.employee.EmployeeRoleRepository; +import com.goi.erp.token.TokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final EmployeeRepository employeeRepository; + private final EmployeeRoleRepository employeeRoleRepository; + private final TokenRepository tokenRepository; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + // 인증 API는 필터링 제외 + if (request.getServletPath().contains("/api/v1/auth")) { + filterChain.doFilter(request, response); + return; + } + + final String authHeader = request.getHeader("Authorization"); + final String jwt; + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + jwt = authHeader.substring(7); + final String empUuidStr = jwtService.extractUsername(jwt); + + if (empUuidStr != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UUID empUuid = UUID.fromString(empUuidStr); + + // Employee 조회 + Employee employee = employeeRepository.findByEmpUuid(empUuid) + .orElseThrow(() -> new RuntimeException("Employee not found")); + // Role 조회 + List roles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId()); + EmployeeDetails employeeDetails = new EmployeeDetails(employee, roles); + + // DB 토큰 검증 + boolean isTokenValid = tokenRepository.findByToken(jwt) + .map(t -> !t.isExpired() && !t.isRevoked()) + .orElse(false); + + // JWT 유효성 검증 + DB 토큰 검증 + if (jwtService.isTokenValid(jwt, employee) && isTokenValid) { + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + employeeDetails, + null, + employeeDetails.getAuthorities() + ); + authToken.setDetails( + new WebAuthenticationDetailsSource().buildDetails(request) + ); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/goi/erp/config/JwtService.java b/src/main/java/com/goi/erp/config/JwtService.java new file mode 100644 index 0000000..91f9ada --- /dev/null +++ b/src/main/java/com/goi/erp/config/JwtService.java @@ -0,0 +1,132 @@ +package com.goi.erp.config; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.io.Encoders; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import com.goi.erp.employee.Employee; + +@Service +public class JwtService { + + @Value("${application.security.jwt.secret-key}") + private String secretKey; + + @Value("${application.security.jwt.expiration}") + private long jwtExpiration; + + @Value("${application.security.jwt.refresh-token.expiration}") + private long refreshExpiration; + + // =================== 기존 UserDetails용 =================== + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + public String generateToken(Employee employee, List roles, List permissions) { + Map extraClaims = new HashMap<>(); + extraClaims.put("roles", roles); + + // Admin 계정 여부 확인 + boolean isAdmin = roles.stream().anyMatch(r -> r.equalsIgnoreCase("Admin")); + + if (isAdmin) { + // Admin이면 permissions를 ALL로 단순화 + extraClaims.put("permissions", List.of("ALL")); + } else { + // 일반 계정이면 상세 권한 넣기 + extraClaims.put("permissions", permissions); + } + + return buildToken(extraClaims, employee.getEmpUuid().toString(), jwtExpiration); + } + + public String generateRefreshToken(Employee employee, List roles, List permissions) { + Map extraClaims = new HashMap<>(); + extraClaims.put("roles", roles); + // Admin 계정 여부 확인 + boolean isAdmin = roles.stream().anyMatch(r -> r.equalsIgnoreCase("Admin")); + + if (isAdmin) { + // Admin이면 permissions를 ALL로 단순화 + extraClaims.put("permissions", List.of("ALL")); + } else { + // 일반 계정이면 상세 권한 넣기 + extraClaims.put("permissions", permissions); + } + return buildToken(extraClaims, employee.getEmpUuid().toString(), refreshExpiration); + } + + private String buildToken(Map extraClaims, String subject, long expiration) { + return Jwts.builder().setClaims(extraClaims).setSubject(subject) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSignInKey(), SignatureAlgorithm.HS256).compact(); + } + + public boolean isTokenValid(String token, Employee employee) { + final String username = extractUsername(token); + return (username.equals(employee.getEmpUuid().toString())) && !isTokenExpired(token); + } + + private boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + private Claims extractAllClaims(String token) { + return Jwts.parserBuilder().setSigningKey(getSignInKey()).build().parseClaimsJws(token).getBody(); + } + + private Key getSignInKey() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + return Keys.hmacShaKeyFor(keyBytes); + } + + public static void main(String[] args) { + JwtService jwtService = new JwtService(); + jwtService.secretKey = "D0HaHnTPKLkUO9ULL1Ulm6XDZjhzuFtvTCcxTxSoCS8="; + + String token = "eyJhbGciOiJIUzI1NiJ9.eyJwZXJtaXNzaW9ucyI6WyJIOlI6UCIsIk86QzpBIiwiTzpSOkEiLCJPOlU6QSIsIk86RDpBIiwiUzpDOkEiLCJTOlI6QSIsIlM6VTpBIl0sInJvbGVzIjpbIk9wZXJhdGlvbnMgTWFuYWdlciJdLCJzdWIiOiJmZGE1NGZkZS03MTBmLTQ4ZDItYTRmYi00NzM2YjJhM2RhNWEiLCJpYXQiOjE3NjMxMzU4MzMsImV4cCI6MTc2MzIyMjIzM30.ie38b2JnkP3k4Vz7TzAwI7oRgOsIFYf0yMYADq5EhNM"; + + // user 정보 + Claims claims = jwtService.extractAllClaims(token); + + System.out.println("Subject (emp_uuid): " + claims.getSubject()); + System.out.println("Roles: " + claims.get("roles")); + System.out.println("Roles: " + claims.get("permissions")); + System.out.println("IssuedAt: " + claims.getIssuedAt()); + System.out.println("Expiration: " + claims.getExpiration()); + + // 모든 Claims 확인 +// Claims claims = Jwts.parserBuilder() +// .setSigningKey(Keys.hmacShaKeyFor("".getBytes())) +// .build() +// .parseClaimsJws(token) +// .getBody(); + + System.out.println("Claims: " + claims); + } +} diff --git a/src/main/java/com/goi/erp/config/LogoutService.java b/src/main/java/com/goi/erp/config/LogoutService.java new file mode 100644 index 0000000..7115381 --- /dev/null +++ b/src/main/java/com/goi/erp/config/LogoutService.java @@ -0,0 +1,40 @@ +package com.goi.erp.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Service; + +import com.goi.erp.token.TokenRepository; + +@Service +@RequiredArgsConstructor +public class LogoutService implements LogoutHandler { + + private final TokenRepository tokenRepository; + + @Override + public void logout( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) { + final String authHeader = request.getHeader("Authorization"); + final String jwt; + if (authHeader == null ||!authHeader.startsWith("Bearer ")) { + return; + } + jwt = authHeader.substring(7); + var storedToken = tokenRepository.findByToken(jwt) + .orElse(null); + if (storedToken != null) { + storedToken.setExpired(true); + storedToken.setRevoked(true); + tokenRepository.save(storedToken); + SecurityContextHolder.clearContext(); + } + } +} diff --git a/src/main/java/com/goi/erp/config/OpenApiConfig.java b/src/main/java/com/goi/erp/config/OpenApiConfig.java new file mode 100644 index 0000000..1050bcc --- /dev/null +++ b/src/main/java/com/goi/erp/config/OpenApiConfig.java @@ -0,0 +1,54 @@ +package com.goi.erp.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.info.License; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.annotations.servers.Server; + +@OpenAPIDefinition( + info = @Info( + contact = @Contact( + name = "Alibou", + email = "contact@aliboucoding.com", + url = "https://aliboucoding.com/course" + ), + description = "OpenApi documentation for Spring Security", + title = "OpenApi specification - Alibou", + version = "1.0", + license = @License( + name = "Licence name", + url = "https://some-url.com" + ), + termsOfService = "Terms of service" + ), + servers = { + @Server( + description = "Local ENV", + url = "http://localhost:8080" + ), + @Server( + description = "PROD ENV", + url = "https://aliboucoding.com/course" + ) + }, + security = { + @SecurityRequirement( + name = "bearerAuth" + ) + } +) +@SecurityScheme( + name = "bearerAuth", + description = "JWT auth description", + scheme = "bearer", + type = SecuritySchemeType.HTTP, + bearerFormat = "JWT", + in = SecuritySchemeIn.HEADER +) +public class OpenApiConfig { +} diff --git a/src/main/java/com/goi/erp/config/SecurityConfiguration.java b/src/main/java/com/goi/erp/config/SecurityConfiguration.java new file mode 100644 index 0000000..6b0bcd4 --- /dev/null +++ b/src/main/java/com/goi/erp/config/SecurityConfiguration.java @@ -0,0 +1,81 @@ +package com.goi.erp.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.core.context.SecurityContextHolder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutHandler; + +import static com.goi.erp.user.Permission.ADMIN_CREATE; +import static com.goi.erp.user.Permission.ADMIN_DELETE; +import static com.goi.erp.user.Permission.ADMIN_READ; +import static com.goi.erp.user.Permission.ADMIN_UPDATE; +import static com.goi.erp.user.Permission.MANAGER_CREATE; +import static com.goi.erp.user.Permission.MANAGER_DELETE; +import static com.goi.erp.user.Permission.MANAGER_READ; +import static com.goi.erp.user.Permission.MANAGER_UPDATE; +import static com.goi.erp.user.Role.ADMIN; +import static com.goi.erp.user.Role.MANAGER; +import static org.springframework.http.HttpMethod.DELETE; +import static org.springframework.http.HttpMethod.GET; +import static org.springframework.http.HttpMethod.POST; +import static org.springframework.http.HttpMethod.PUT; +import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfiguration { + + private static final String[] WHITE_LIST_URL = { + "/auth/**", + "/v2/api-docs", + "/v3/api-docs", + "/v3/api-docs/**", + "/swagger-resources", + "/swagger-resources/**", + "/configuration/ui", + "/configuration/security", + "/swagger-ui/**", + "/webjars/**", + "/swagger-ui.html"}; + private final JwtAuthenticationFilter jwtAuthFilter; + private final AuthenticationProvider authenticationProvider; + private final LogoutHandler logoutHandler; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(req -> + req.requestMatchers(WHITE_LIST_URL) + .permitAll() + .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name()) + .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name()) + .requestMatchers(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name()) + .requestMatchers(PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.name(), MANAGER_UPDATE.name()) + .requestMatchers(DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.name(), MANAGER_DELETE.name()) + .anyRequest() + .authenticated() + ) + .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)) + .authenticationProvider(authenticationProvider) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .logout(logout -> + logout.logoutUrl("/api/v1/auth/logout") + .addLogoutHandler(logoutHandler) + .logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext()) + ) + ; + + return http.build(); + } +} diff --git a/src/main/java/com/goi/erp/demo/AdminController.java b/src/main/java/com/goi/erp/demo/AdminController.java new file mode 100644 index 0000000..b0f6b2f --- /dev/null +++ b/src/main/java/com/goi/erp/demo/AdminController.java @@ -0,0 +1,40 @@ +package com.goi.erp.demo; + +import io.swagger.v3.oas.annotations.Hidden; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin") +@PreAuthorize("hasRole('ADMIN')") +public class AdminController { + + @GetMapping + @PreAuthorize("hasAuthority('admin:read')") + public String get() { + return "GET:: admin controller"; + } + @PostMapping + @PreAuthorize("hasAuthority('admin:create')") + @Hidden + public String post() { + return "POST:: admin controller"; + } + @PutMapping + @PreAuthorize("hasAuthority('admin:update')") + @Hidden + public String put() { + return "PUT:: admin controller"; + } + @DeleteMapping + @PreAuthorize("hasAuthority('admin:delete')") + @Hidden + public String delete() { + return "DELETE:: admin controller"; + } +} diff --git a/src/main/java/com/goi/erp/demo/DemoController.java b/src/main/java/com/goi/erp/demo/DemoController.java new file mode 100644 index 0000000..24b6cf0 --- /dev/null +++ b/src/main/java/com/goi/erp/demo/DemoController.java @@ -0,0 +1,19 @@ +package com.goi.erp.demo; + +import io.swagger.v3.oas.annotations.Hidden; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/demo-controller") +@Hidden +public class DemoController { + + @GetMapping + public ResponseEntity sayHello() { + return ResponseEntity.ok("Hello from secured endpoint"); + } + +} diff --git a/src/main/java/com/goi/erp/demo/ManagementController.java b/src/main/java/com/goi/erp/demo/ManagementController.java new file mode 100644 index 0000000..c59d240 --- /dev/null +++ b/src/main/java/com/goi/erp/demo/ManagementController.java @@ -0,0 +1,50 @@ +package com.goi.erp.demo; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/management") +@Tag(name = "Management") +public class ManagementController { + + + @Operation( + description = "Get endpoint for manager", + summary = "This is a summary for management get endpoint", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "403" + ) + } + + ) + @GetMapping + public String get() { + return "GET:: management controller"; + } + @PostMapping + public String post() { + return "POST:: management controller"; + } + @PutMapping + public String put() { + return "PUT:: management controller"; + } + @DeleteMapping + public String delete() { + return "DELETE:: management controller"; + } +} diff --git a/src/main/java/com/goi/erp/employee/Employee.java b/src/main/java/com/goi/erp/employee/Employee.java new file mode 100644 index 0000000..e4bb169 --- /dev/null +++ b/src/main/java/com/goi/erp/employee/Employee.java @@ -0,0 +1,49 @@ +package com.goi.erp.employee; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "employee") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Employee { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "emp_id") + private Integer empId; // 내부 PK, 외부 노출 X + + @Column(name = "emp_uuid", unique = true, nullable = false) + private UUID empUuid; // 외부 키로 사용 + + @Column(name = "emp_login_id", unique = true, nullable = false) + private String empLoginId; + + @Column(name = "emp_login_password", nullable = false) + private String empLoginPassword; + + @Column(name = "emp_first_name") + private String empFirstName; + + @Column(name = "emp_last_name") + private String empLastName; + + @Column(name = "emp_dept_id") + private Integer empDeptId; + + @Column(name = "emp_status", columnDefinition = "CHAR(1)") + private String empStatus; +} + diff --git a/src/main/java/com/goi/erp/employee/EmployeeDetails.java b/src/main/java/com/goi/erp/employee/EmployeeDetails.java new file mode 100644 index 0000000..888df71 --- /dev/null +++ b/src/main/java/com/goi/erp/employee/EmployeeDetails.java @@ -0,0 +1,62 @@ +package com.goi.erp.employee; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +public class EmployeeDetails implements UserDetails { + // 이 UID는 객체를 직렬화 후 다시 역직렬화할 때, 클래스 버전이 맞는지 확인하는 용도 + private static final long serialVersionUID = 1L; + + private final Employee employee; + private final List authorities; + + public EmployeeDetails(Employee employee, List roles) { + this.employee = employee; + // EmployeeRole → GrantedAuthority 변환 + this.authorities = roles.stream() + .map(er -> new SimpleGrantedAuthority(er.getRoleInfo().getRoleName())) + .collect(Collectors.toList()); + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getPassword() { + return employee.getEmpLoginPassword(); + } + + @Override + public String getUsername() { + return employee.getEmpLoginId(); + } + + @Override + public boolean isAccountNonExpired() { + return true; // 필요에 따라 Employee 상태로 제어 가능 + } + + @Override + public boolean isAccountNonLocked() { + return !"I".equals(employee.getEmpStatus()); // 예: 'I'면 잠금 + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return "A".equals(employee.getEmpStatus()); // Active 상태만 사용 가능 + } +} diff --git a/src/main/java/com/goi/erp/employee/EmployeeDetailsService.java b/src/main/java/com/goi/erp/employee/EmployeeDetailsService.java new file mode 100644 index 0000000..1ec43a1 --- /dev/null +++ b/src/main/java/com/goi/erp/employee/EmployeeDetailsService.java @@ -0,0 +1,30 @@ +package com.goi.erp.employee; + +import lombok.RequiredArgsConstructor; +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.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class EmployeeDetailsService implements UserDetailsService { + + private final EmployeeRepository employeeRepository; + private final EmployeeRoleRepository employeeRoleRepository; + + @Override + public UserDetails loadUserByUsername(String empLoginId) throws UsernameNotFoundException { + // Employee 조회 + Employee employee = employeeRepository.findByEmpLoginId(empLoginId) + .orElseThrow(() -> new UsernameNotFoundException("Employee not found")); + + // EmployeeRole 조회 (활성화된 역할만) + List roles = employeeRoleRepository.findActiveRolesByEmployeeId(employee.getEmpId()); + + // EmployeeDetails에 Employee + 역할 전달 + return new EmployeeDetails(employee, roles); + } +} diff --git a/src/main/java/com/goi/erp/employee/EmployeeRepository.java b/src/main/java/com/goi/erp/employee/EmployeeRepository.java new file mode 100644 index 0000000..d671066 --- /dev/null +++ b/src/main/java/com/goi/erp/employee/EmployeeRepository.java @@ -0,0 +1,13 @@ +package com.goi.erp.employee; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface EmployeeRepository extends JpaRepository { + Optional findByEmpLoginId(String empLoginId); + Optional findByEmpUuid(UUID empUuid); +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/employee/EmployeeRole.java b/src/main/java/com/goi/erp/employee/EmployeeRole.java new file mode 100644 index 0000000..6f43600 --- /dev/null +++ b/src/main/java/com/goi/erp/employee/EmployeeRole.java @@ -0,0 +1,49 @@ +package com.goi.erp.employee; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; +import java.util.UUID; + +import com.goi.erp.role.RoleInfo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "employee_role") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class EmployeeRole { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "emr_id") + private Integer emrId; // 내부 PK + + @Column(name = "emr_uuid", unique = true, nullable = false) + private UUID emrUuid; // 외부용 UUID + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "emr_emp_id", nullable = false) // DB 컬럼명 지정 + private Employee employee; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "emr_role_id", nullable = false) // DB 컬럼명 지정 + private RoleInfo roleInfo; + + @Column(name = "emr_revoked_at") + private LocalDateTime emrRevokedAt; +} + diff --git a/src/main/java/com/goi/erp/employee/EmployeeRoleRepository.java b/src/main/java/com/goi/erp/employee/EmployeeRoleRepository.java new file mode 100644 index 0000000..5bf3d3c --- /dev/null +++ b/src/main/java/com/goi/erp/employee/EmployeeRoleRepository.java @@ -0,0 +1,19 @@ +package com.goi.erp.employee; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface EmployeeRoleRepository extends JpaRepository { + + @Query(""" + SELECT er + FROM EmployeeRole er + WHERE er.employee.id = :empId + AND er.emrRevokedAt IS NULL + """) + List findActiveRolesByEmployeeId(Integer empId); +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/permission/PermissionInfo.java b/src/main/java/com/goi/erp/permission/PermissionInfo.java new file mode 100644 index 0000000..0b9e865 --- /dev/null +++ b/src/main/java/com/goi/erp/permission/PermissionInfo.java @@ -0,0 +1,46 @@ +package com.goi.erp.permission; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +import java.util.UUID; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "permission_info", + uniqueConstraints = @UniqueConstraint(columnNames = {"perm_module", "perm_action", "perm_scope"})) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PermissionInfo { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "perm_id") + private Integer permId; + + @Column(name = "perm_uuid", nullable = false, updatable = false) + private UUID permUuid; + + @Column(name = "perm_module", nullable = false) + private String permModule; + + @Column(name = "perm_action", nullable = false, length = 1) + private String permAction; // C/R/U/D + + @Column(name = "perm_scope", nullable = false, length = 10) + private String permScope; // Self / Part / All + + @Column(name = "perm_desc") + private String permDesc; + +} diff --git a/src/main/java/com/goi/erp/permission/PermissionInfoRepository.java b/src/main/java/com/goi/erp/permission/PermissionInfoRepository.java new file mode 100644 index 0000000..95334dd --- /dev/null +++ b/src/main/java/com/goi/erp/permission/PermissionInfoRepository.java @@ -0,0 +1,15 @@ +package com.goi.erp.permission; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface PermissionInfoRepository extends JpaRepository { + Optional findByPermUuid(UUID permUuid); + + // 특정 모듈 + 액션 + 범위에 해당하는 권한 조회 + Optional findByPermModuleAndPermActionAndPermScope(String module, String action, String scope); +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/role/RoleInfo.java b/src/main/java/com/goi/erp/role/RoleInfo.java new file mode 100644 index 0000000..786400b --- /dev/null +++ b/src/main/java/com/goi/erp/role/RoleInfo.java @@ -0,0 +1,48 @@ +package com.goi.erp.role; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; + +import java.util.List; +import java.util.UUID; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "role_info") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RoleInfo { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "role_id") + private Integer roleId; + + @Column(name = "role_uuid", unique = true, nullable = false) + private UUID roleUuid; + + @Column(name = "role_name", unique = true, nullable = false) + private String roleName; + + @Column(name = "role_desc") + private String roleDesc; + + @Column(name = "role_level") + private Integer roleLevel; + + @OneToMany(mappedBy = "roleInfo", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private List rolePermissions; + +} diff --git a/src/main/java/com/goi/erp/role/RoleInfoRepository.java b/src/main/java/com/goi/erp/role/RoleInfoRepository.java new file mode 100644 index 0000000..f1d6b77 --- /dev/null +++ b/src/main/java/com/goi/erp/role/RoleInfoRepository.java @@ -0,0 +1,14 @@ +package com.goi.erp.role; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface RoleInfoRepository extends JpaRepository { + Optional findByRoleUuid(UUID roleUuid); + + Optional findByRoleName(String roleName); +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/role/RolePermission.java b/src/main/java/com/goi/erp/role/RolePermission.java new file mode 100644 index 0000000..14d5b0e --- /dev/null +++ b/src/main/java/com/goi/erp/role/RolePermission.java @@ -0,0 +1,40 @@ +package com.goi.erp.role; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import com.goi.erp.permission.PermissionInfo; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "role_permission") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RolePermission { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "rpr_id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "rpr_role_id", nullable = false) + private RoleInfo roleInfo; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "rpr_perm_id", nullable = false) + private PermissionInfo permissionInfo; + +} diff --git a/src/main/java/com/goi/erp/role/RolePermissionRepository.java b/src/main/java/com/goi/erp/role/RolePermissionRepository.java new file mode 100644 index 0000000..cd00556 --- /dev/null +++ b/src/main/java/com/goi/erp/role/RolePermissionRepository.java @@ -0,0 +1,26 @@ +package com.goi.erp.role; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface RolePermissionRepository extends JpaRepository { + + // 특정 Role의 모든 권한 조회 + @Query("SELECT r FROM RolePermission r WHERE r.roleInfo.id = :roleId") + List findByRoleId(@Param("roleId") Integer roleId); + + // Role과 Permission 매핑 존재 여부 확인 + @Query(""" + SELECT CASE WHEN COUNT(r) > 0 THEN true ELSE false END + FROM RolePermission r + WHERE r.roleInfo.id = :roleId + AND r.permissionInfo.id = :permId + """) + boolean existsByRoleInfoAndPermissionInfo(@Param("roleId") Integer roleId, + @Param("permId") Integer permId); +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/token/Token.java b/src/main/java/com/goi/erp/token/Token.java new file mode 100644 index 0000000..1551330 --- /dev/null +++ b/src/main/java/com/goi/erp/token/Token.java @@ -0,0 +1,50 @@ +package com.goi.erp.token; + +import com.goi.erp.employee.Employee; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "employee_token") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Token { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "emt_id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "emt_emp_id", nullable = false) + private Employee employee; + + @Column(name = "emt_token", nullable = false) + private String token; + + @Enumerated(EnumType.STRING) + @Column(name = "emt_token_type") + private TokenType tokenType; + + @Column(name = "emt_is_expired") + private boolean expired; + + @Column(name = "emt_is_revoked") + private boolean revoked; +} diff --git a/src/main/java/com/goi/erp/token/TokenRepository.java b/src/main/java/com/goi/erp/token/TokenRepository.java new file mode 100644 index 0000000..01e2788 --- /dev/null +++ b/src/main/java/com/goi/erp/token/TokenRepository.java @@ -0,0 +1,22 @@ +package com.goi.erp.token; + +import com.goi.erp.employee.Employee; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface TokenRepository extends JpaRepository { + + @Query(""" + select t from Token t + where t.employee.id = :employeeId + and (t.expired = false or t.revoked = false) + """) + List findAllValidTokenByEmployee(Integer employeeId); + + Optional findByToken(String token); + + List findByEmployee(Employee employee); +} diff --git a/src/main/java/com/goi/erp/token/TokenType.java b/src/main/java/com/goi/erp/token/TokenType.java new file mode 100644 index 0000000..d062f85 --- /dev/null +++ b/src/main/java/com/goi/erp/token/TokenType.java @@ -0,0 +1,5 @@ +package com.goi.erp.token; + +public enum TokenType { + BEARER +} diff --git a/src/main/java/com/goi/erp/user/ChangePasswordRequest.java b/src/main/java/com/goi/erp/user/ChangePasswordRequest.java new file mode 100644 index 0000000..fbee5c1 --- /dev/null +++ b/src/main/java/com/goi/erp/user/ChangePasswordRequest.java @@ -0,0 +1,15 @@ +package com.goi.erp.user; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Builder +public class ChangePasswordRequest { + + private String currentPassword; + private String newPassword; + private String confirmationPassword; +} diff --git a/src/main/java/com/goi/erp/user/Permission.java b/src/main/java/com/goi/erp/user/Permission.java new file mode 100644 index 0000000..99fef3f --- /dev/null +++ b/src/main/java/com/goi/erp/user/Permission.java @@ -0,0 +1,22 @@ +package com.goi.erp.user; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum Permission { + + ADMIN_READ("admin:read"), + ADMIN_UPDATE("admin:update"), + ADMIN_CREATE("admin:create"), + ADMIN_DELETE("admin:delete"), + MANAGER_READ("management:read"), + MANAGER_UPDATE("management:update"), + MANAGER_CREATE("management:create"), + MANAGER_DELETE("management:delete") + + ; + + @Getter + private final String permission; +} diff --git a/src/main/java/com/goi/erp/user/Role.java b/src/main/java/com/goi/erp/user/Role.java new file mode 100644 index 0000000..439dd14 --- /dev/null +++ b/src/main/java/com/goi/erp/user/Role.java @@ -0,0 +1,59 @@ +package com.goi.erp.user; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import static com.goi.erp.user.Permission.ADMIN_CREATE; +import static com.goi.erp.user.Permission.ADMIN_DELETE; +import static com.goi.erp.user.Permission.ADMIN_READ; +import static com.goi.erp.user.Permission.ADMIN_UPDATE; +import static com.goi.erp.user.Permission.MANAGER_CREATE; +import static com.goi.erp.user.Permission.MANAGER_DELETE; +import static com.goi.erp.user.Permission.MANAGER_READ; +import static com.goi.erp.user.Permission.MANAGER_UPDATE; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +public enum Role { + + USER(Collections.emptySet()), + ADMIN( + Set.of( + ADMIN_READ, + ADMIN_UPDATE, + ADMIN_DELETE, + ADMIN_CREATE, + MANAGER_READ, + MANAGER_UPDATE, + MANAGER_DELETE, + MANAGER_CREATE + ) + ), + MANAGER( + Set.of( + MANAGER_READ, + MANAGER_UPDATE, + MANAGER_DELETE, + MANAGER_CREATE + ) + ) + + ; + + @Getter + private final Set permissions; + + public List getAuthorities() { + var authorities = getPermissions() + .stream() + .map(permission -> new SimpleGrantedAuthority(permission.getPermission())) + .collect(Collectors.toList()); + authorities.add(new SimpleGrantedAuthority("ROLE_" + this.name())); + return authorities; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..68daa74 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,27 @@ +spring: + datasource: + url: jdbc:postgresql://${DB_HOST}:${DB_PORT:5432}/${DB_NAME:goi} + username: ${DB_USER} + password: ${DB_PASSWORD} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + format_sql: true + database: postgresql + database-platform: org.hibernate.dialect.PostgreSQLDialect + +application: + security: + jwt: + secret-key: ${SECRET_KEY} + expiration: 86400000 # a day + refresh-token: + expiration: 604800000 # 7 days +server: + port: 8080 + servlet: + context-path: /auth-service \ No newline at end of file diff --git a/src/test/java/com/goi/security/SecurityApplicationTests.java b/src/test/java/com/goi/security/SecurityApplicationTests.java new file mode 100644 index 0000000..2e9b375 --- /dev/null +++ b/src/test/java/com/goi/security/SecurityApplicationTests.java @@ -0,0 +1,13 @@ +package com.goi.security; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SecurityApplicationTests { + + @Test + void contextLoads() { + } + +}