diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..f37a852d Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/docker-ci.yaml b/.github/workflows/docker-ci.yaml index a876a80b..a7ece8bd 100644 --- a/.github/workflows/docker-ci.yaml +++ b/.github/workflows/docker-ci.yaml @@ -2,12 +2,9 @@ name: Docker CI/CD on: pull_request: - branches: - - 'develop' + branches: [ develop ] push: - branches: - - 'develop' - + branches: [ develop ] workflow_dispatch: jobs: @@ -24,6 +21,12 @@ jobs: java-version: '21' distribution: 'temurin' + - name: Clone external repo with jar into libs/ + run: | + mkdir -p libs + git clone https://x-access-token:${{ secrets.GH_PAT }}@github.com/mosu-dev/mosu-kmc-jar.git temp-jar + cp temp-jar/*.jar libs/ + - name: Build with Gradle run: ./gradlew clean build -x test @@ -34,9 +37,7 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Build Docker image - run: | - docker build -t kangtaehyun1107/mosu-server:${{ github.sha }} . - + run: docker build -t kangtaehyun1107/mosu-server:${{ github.sha }} . + working-directory: - name: Push Docker image - run: | - docker push kangtaehyun1107/mosu-server:${{ github.sha }} \ No newline at end of file + run: docker push kangtaehyun1107/mosu-server:${{ github.sha }} diff --git a/.gitignore b/.gitignore index ff23c2ed..6cd9d987 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,6 @@ out/ docker-compose/.env docker-compose/.env.local -/logs/app.log + +/logs/** +/libs/** \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index be8c34a3..c4084ee4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:21-jdk -ARG JAR_FILE=build/libs/*.jar -ADD ${JAR_FILE} app.jar -ENTRYPOINT ["java", "-Duser.timezone=GMT+9", "-Djava.security.egd=file:/dev/./urandom", "-jar", "/app.jar"] \ No newline at end of file +FROM amazoncorretto:21 +COPY build/libs/*SNAPSHOT.war app.war + +ENTRYPOINT ["java", "-Duser.timezone=GMT+9", "-Djava.security.egd=file:/dev/./urandom", "-jar", "/app.war"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 3a4f81c3..70072ffd 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.5' id 'io.spring.dependency-management' version '1.1.7' + id 'war' } group = 'life.mosu' @@ -24,7 +25,7 @@ repositories { } dependencies { - + implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' @@ -32,14 +33,18 @@ dependencies { testImplementation 'org.projectlombok:lombok' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' -// developmentOnly 'org.springframework.boot:spring-boot-devtools' + developmentOnly 'org.springframework.boot:spring-boot-devtools' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // 인증사 관련 의존성 + implementation 'javax.servlet:jstl:1.2' + implementation "org.apache.tomcat.embed:tomcat-embed-jasper" + // swagger - implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0" + implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9" // jwt implementation 'io.jsonwebtoken:jjwt-api:0.11.5' @@ -58,6 +63,13 @@ dependencies { implementation 'org.flywaydb:flyway-mysql' runtimeOnly 'com.mysql:mysql-connector-j' + // Testcontainers + testImplementation 'org.springframework.boot:spring-boot-testcontainers:3.3.5' + testImplementation 'org.testcontainers:testcontainers:1.19.3' + testImplementation 'org.testcontainers:junit-jupiter:1.19.3' + testImplementation 'org.testcontainers:mysql:1.20.0' + + // security implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' @@ -96,6 +108,12 @@ dependencies { testImplementation 'org.testcontainers:mysql:1.20.0' } +configurations.configureEach { + resolutionStrategy { + force 'org.apache.commons:commons-lang3:3.18.0' + } +} + tasks.named('test') { useJUnitPlatform() } diff --git a/gradle/.DS_Store b/gradle/.DS_Store new file mode 100644 index 00000000..ac242498 Binary files /dev/null and b/gradle/.DS_Store differ diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 00000000..6afdc204 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/main/.DS_Store b/src/main/.DS_Store new file mode 100644 index 00000000..51ecff9d Binary files /dev/null and b/src/main/.DS_Store differ diff --git a/src/main/java/life/mosu/mosuserver/application/auth/AccessTokenService.java b/src/main/java/life/mosu/mosuserver/application/auth/AccessTokenService.java deleted file mode 100644 index 560bb259..00000000 --- a/src/main/java/life/mosu/mosuserver/application/auth/AccessTokenService.java +++ /dev/null @@ -1,19 +0,0 @@ -package life.mosu.mosuserver.application.auth; - -import life.mosu.mosuserver.domain.user.UserJpaRepository; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; - -@Service -public class AccessTokenService extends JwtTokenService { - - @Autowired - public AccessTokenService( - @Value("${jwt.access-token.expire-time}") final Long expireTime, - @Value("${jwt.secret}") final String secretKey, - final UserJpaRepository userRepositoy - ) { - super(expireTime, secretKey, "Access", "Authorization", userRepositoy); - } -} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/auth/AuthService.java b/src/main/java/life/mosu/mosuserver/application/auth/AuthService.java index 6ae1f442..79f358e8 100644 --- a/src/main/java/life/mosu/mosuserver/application/auth/AuthService.java +++ b/src/main/java/life/mosu/mosuserver/application/auth/AuthService.java @@ -1,6 +1,6 @@ package life.mosu.mosuserver.application.auth; -import jakarta.servlet.http.HttpServletRequest; +import life.mosu.mosuserver.application.auth.provider.AuthTokenManager; import life.mosu.mosuserver.domain.profile.ProfileJpaRepository; import life.mosu.mosuserver.domain.user.UserJpaEntity; import life.mosu.mosuserver.global.exception.CustomRuntimeException; @@ -44,7 +44,7 @@ public LoginCommandResponse login(final LoginRequest request) { } @Transactional - public Token reissueAccessToken(final HttpServletRequest servletRequest) { - return authTokenManager.reissueAccessToken(servletRequest); + public Token reissueAccessToken(final String refreshTokenHeader) { + return authTokenManager.reissueAccessToken(refreshTokenHeader); } } diff --git a/src/main/java/life/mosu/mosuserver/application/auth/AuthTokenManager.java b/src/main/java/life/mosu/mosuserver/application/auth/AuthTokenManager.java deleted file mode 100644 index 2b983234..00000000 --- a/src/main/java/life/mosu/mosuserver/application/auth/AuthTokenManager.java +++ /dev/null @@ -1,44 +0,0 @@ -package life.mosu.mosuserver.application.auth; - -import jakarta.servlet.http.HttpServletRequest; -import life.mosu.mosuserver.domain.user.UserJpaEntity; -import life.mosu.mosuserver.global.exception.CustomRuntimeException; -import life.mosu.mosuserver.global.exception.ErrorCode; -import life.mosu.mosuserver.presentation.auth.dto.Token; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class AuthTokenManager { - - private final AccessTokenService accessTokenService; - private final RefreshTokenService refreshTokenService; - - public Token generateAuthToken(final UserJpaEntity user) { - final String accessToken = accessTokenService.generateJwtToken(user); - final String refreshToken = refreshTokenService.generateJwtToken(user); - - refreshTokenService.cacheRefreshToken(user.getId(), refreshToken); - - return Token.of(JwtTokenService.BEARER_TYPE, accessToken, refreshToken, - accessTokenService.expireTime, refreshTokenService.expireTime); - } - - public Token reissueAccessToken(final HttpServletRequest servletRequest) { - final String refreshTokenString = refreshTokenService.resolveToken(servletRequest); - if (refreshTokenString == null) { - throw new CustomRuntimeException(ErrorCode.NOT_FOUND_REFRESH_TOKEN); - } - - final Authentication authentication = refreshTokenService.getAuthentication( - refreshTokenString); - final PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); - final UserJpaEntity user = principalDetails.user(); - - refreshTokenService.deleteRefreshToken(user.getId()); - - return generateAuthToken(user); - } -} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/auth/DanalVerificationService.java b/src/main/java/life/mosu/mosuserver/application/auth/DanalVerificationService.java deleted file mode 100644 index 5be950c8..00000000 --- a/src/main/java/life/mosu/mosuserver/application/auth/DanalVerificationService.java +++ /dev/null @@ -1,12 +0,0 @@ -package life.mosu.mosuserver.application.auth; - -import org.springframework.stereotype.Service; - -@Service -public class DanalVerificationService implements VerificationService { - //TODO: Danal 인증 서비스 구현 - @Override - public boolean verify(final String verificationCode, final String phoneNumber) { - return true; - } -} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/auth/PrincipalDetails.java b/src/main/java/life/mosu/mosuserver/application/auth/PrincipalDetails.java index 14f18f33..0dda5f2c 100644 --- a/src/main/java/life/mosu/mosuserver/application/auth/PrincipalDetails.java +++ b/src/main/java/life/mosu/mosuserver/application/auth/PrincipalDetails.java @@ -1,7 +1,6 @@ package life.mosu.mosuserver.application.auth; import life.mosu.mosuserver.domain.user.UserJpaEntity; -import lombok.Getter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @@ -10,7 +9,9 @@ import java.util.Collection; import java.util.List; -public record PrincipalDetails(@Getter UserJpaEntity user) implements UserDetails { +public record PrincipalDetails( + UserJpaEntity user +) implements UserDetails { @Override public Collection getAuthorities() { diff --git a/src/main/java/life/mosu/mosuserver/application/auth/PrincipalDetailsService.java b/src/main/java/life/mosu/mosuserver/application/auth/PrincipalDetailsService.java index 6437fc8d..755e41a6 100644 --- a/src/main/java/life/mosu/mosuserver/application/auth/PrincipalDetailsService.java +++ b/src/main/java/life/mosu/mosuserver/application/auth/PrincipalDetailsService.java @@ -18,6 +18,7 @@ public class PrincipalDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException { - return new PrincipalDetails(userRepository.findByLoginId(username).orElseThrow(() -> new CustomRuntimeException(ErrorCode.USER_NOT_FOUND))); + return new PrincipalDetails(userRepository.findByLoginId(username) + .orElseThrow(() -> new CustomRuntimeException(ErrorCode.USER_NOT_FOUND))); } } diff --git a/src/main/java/life/mosu/mosuserver/application/auth/SignUpService.java b/src/main/java/life/mosu/mosuserver/application/auth/SignUpService.java index 6ad6d345..56609186 100644 --- a/src/main/java/life/mosu/mosuserver/application/auth/SignUpService.java +++ b/src/main/java/life/mosu/mosuserver/application/auth/SignUpService.java @@ -2,11 +2,11 @@ import life.mosu.mosuserver.application.auth.processor.SignUpAccountStepProcessor; import life.mosu.mosuserver.application.auth.processor.SignUpProfileStepProcessor; +import life.mosu.mosuserver.application.auth.provider.AuthTokenManager; import life.mosu.mosuserver.domain.profile.ProfileJpaEntity; import life.mosu.mosuserver.domain.user.UserJpaEntity; import life.mosu.mosuserver.presentation.auth.dto.SignUpAccountRequest; import life.mosu.mosuserver.presentation.auth.dto.SignUpProfileRequest; -import life.mosu.mosuserver.presentation.auth.dto.SignUpRequest; import life.mosu.mosuserver.presentation.auth.dto.Token; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; @@ -23,23 +23,20 @@ public class SignUpService { private final PasswordEncoder passwordEncoder; @Transactional - public Token signUp(final SignUpRequest request) { - //STEP1 - add account into persistence storage - UserJpaEntity user = doAccountStep(request.signUpAccountStep()); - - //STEP2 - add profile into persistence storage - doProfileStep(user.getId(), request.signUpProfileStep()); + public Token signUp(final SignUpAccountRequest request) { + UserJpaEntity user = doAccountStep(request); return authTokenManager.generateAuthToken(user); } + @Transactional + public void registerProfile(final Long userId, final SignUpProfileRequest request) { + ProfileJpaEntity profile = request.toEntity(userId); + signUpProfileStepProcessor.process(profile); + } + private UserJpaEntity doAccountStep(SignUpAccountRequest request) { UserJpaEntity user = request.toAuthEntity(passwordEncoder); return signUpAccountStepProcessor.process(user); } - - private void doProfileStep(Long userId, SignUpProfileRequest request) { - ProfileJpaEntity profile = request.toEntity(userId); - signUpProfileStepProcessor.process(profile); - } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/auth/VerificationService.java b/src/main/java/life/mosu/mosuserver/application/auth/VerificationService.java deleted file mode 100644 index fedcb162..00000000 --- a/src/main/java/life/mosu/mosuserver/application/auth/VerificationService.java +++ /dev/null @@ -1,5 +0,0 @@ -package life.mosu.mosuserver.application.auth; - -public interface VerificationService { - boolean verify(String verificationCode, String phoneNumber); -} diff --git a/src/main/java/life/mosu/mosuserver/application/auth/kmc/KmcEventTxService.java b/src/main/java/life/mosu/mosuserver/application/auth/kmc/KmcEventTxService.java new file mode 100644 index 00000000..4c016a8e --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/auth/kmc/KmcEventTxService.java @@ -0,0 +1,32 @@ +package life.mosu.mosuserver.application.auth.kmc; + +import life.mosu.mosuserver.application.auth.kmc.tx.KmcContext; +import life.mosu.mosuserver.application.auth.kmc.tx.KmcTxEventFactory; +import life.mosu.mosuserver.global.tx.TxEvent; +import life.mosu.mosuserver.global.tx.TxEventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class KmcEventTxService { + private final TxEventPublisher txEventPublisher; + private final KmcTxEventFactory eventFactory; + + @Transactional + public void publishIssueEvent(String certNum, Long expiration) { + TxEvent event = eventFactory.create( + KmcContext.ofSuccess(certNum, expiration) + ); + txEventPublisher.publish(event); + } + + @Transactional + public void publishFailureEvent(String certNum) { + TxEvent event = eventFactory.create( + KmcContext.ofFailure(certNum) + ); + txEventPublisher.publish(event); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcContext.java b/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcContext.java new file mode 100644 index 00000000..262bb42b --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcContext.java @@ -0,0 +1,24 @@ +package life.mosu.mosuserver.application.auth.kmc.tx; + +import life.mosu.mosuserver.domain.auth.signup.SignUpToken; + +public record KmcContext( + String certNum, + Long expiration, + Boolean isSuccess +) { + public static KmcContext ofSuccess(String certNum, Long expiration) { + return new KmcContext(certNum, expiration, true); + } + + public static KmcContext ofFailure(String certNum) { + return new KmcContext(certNum, 0L, false); + } + + public SignUpToken toSignUpToken() { + return SignUpToken.of( + this.certNum, + this.expiration + ); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcIssueTxEvent.java b/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcIssueTxEvent.java new file mode 100644 index 00000000..ecbabf3d --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcIssueTxEvent.java @@ -0,0 +1,10 @@ +package life.mosu.mosuserver.application.auth.kmc.tx; + +import life.mosu.mosuserver.global.tx.TxEvent; + +public class KmcIssueTxEvent extends TxEvent { + + public KmcIssueTxEvent(boolean success, KmcContext context) { + super(success, context); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcTxEventFactory.java b/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcTxEventFactory.java new file mode 100644 index 00000000..49047533 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcTxEventFactory.java @@ -0,0 +1,14 @@ +package life.mosu.mosuserver.application.auth.kmc.tx; + +import life.mosu.mosuserver.global.tx.TxEvent; +import life.mosu.mosuserver.global.tx.TxEventFactory; +import org.springframework.stereotype.Component; + +@Component +public class KmcTxEventFactory implements TxEventFactory { + + @Override + public TxEvent create(KmcContext context) { + return new KmcIssueTxEvent(context.isSuccess(), context); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcTxEventListener.java b/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcTxEventListener.java new file mode 100644 index 00000000..f4ff662e --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcTxEventListener.java @@ -0,0 +1,39 @@ +package life.mosu.mosuserver.application.auth.kmc.tx; + +import life.mosu.mosuserver.domain.auth.signup.SignUpToken; +import life.mosu.mosuserver.domain.auth.signup.SignUpTokenRepository; +import life.mosu.mosuserver.global.tx.TxFailureHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +@Transactional(propagation = Propagation.NOT_SUPPORTED) +public class KmcTxEventListener { + + private final TxFailureHandler kmcFailureHandler; + private final SignUpTokenRepository repository; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void afterCommitHandler(KmcIssueTxEvent event) { + KmcContext ctx = event.getContext(); + log.debug("[AFTER_COMMIT] 커밋 후 처리 시작: certNum={}", ctx.certNum()); + SignUpToken token = ctx.toSignUpToken(); + repository.save(token); + log.debug("[AFTER_COMMIT] 커밋 후 처리 완료: certNum={}", ctx.certNum()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) + public void afterRollbackHandler(KmcIssueTxEvent event) { + KmcContext ctx = event.getContext(); + log.debug("[AFTER_ROLLBACK] 롤백 후 처리 시작: certNum={}", ctx.certNum()); + kmcFailureHandler.handle(event); + log.debug("[AFTER_ROLLBACK] 롤백 후 처리 완료: certNum={}", ctx.certNum()); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcTxFailureHandler.java b/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcTxFailureHandler.java new file mode 100644 index 00000000..c388c00d --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/auth/kmc/tx/KmcTxFailureHandler.java @@ -0,0 +1,21 @@ +package life.mosu.mosuserver.application.auth.kmc.tx; + +import life.mosu.mosuserver.global.exception.AuthenticationException; +import life.mosu.mosuserver.global.tx.TxFailureHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KmcTxFailureHandler implements + TxFailureHandler { + + @Override + public void handle(KmcIssueTxEvent event) { + KmcContext ctx = event.getContext(); + + throw new AuthenticationException("인증 실패", ctx.certNum()); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/auth/provider/AccessTokenProvider.java b/src/main/java/life/mosu/mosuserver/application/auth/provider/AccessTokenProvider.java new file mode 100644 index 00000000..6a2d2f58 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/auth/provider/AccessTokenProvider.java @@ -0,0 +1,19 @@ +package life.mosu.mosuserver.application.auth.provider; + +import life.mosu.mosuserver.domain.user.UserJpaRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class AccessTokenProvider extends JwtTokenProvider { + private static final String TOKEN_TYPE = "Access"; + private static final String HEADER = "Authorization"; + + public AccessTokenProvider( + @Value("${jwt.access-token.expire-time}") final Long expireTime, + @Value("${jwt.secret}") final String secretKey, + final UserJpaRepository userRepository + ) { + super(expireTime, secretKey, TOKEN_TYPE, HEADER, userRepository); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/auth/provider/AuthTokenManager.java b/src/main/java/life/mosu/mosuserver/application/auth/provider/AuthTokenManager.java new file mode 100644 index 00000000..367f8f49 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/auth/provider/AuthTokenManager.java @@ -0,0 +1,43 @@ +package life.mosu.mosuserver.application.auth.provider; + +import life.mosu.mosuserver.application.auth.PrincipalDetails; +import life.mosu.mosuserver.domain.user.UserJpaEntity; +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import life.mosu.mosuserver.global.exception.ErrorCode; +import life.mosu.mosuserver.presentation.auth.dto.Token; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AuthTokenManager { + + private final AccessTokenProvider accessTokenProvider; + private final RefreshTokenProvider refreshTokenProvider; + + public Token generateAuthToken(final UserJpaEntity user) { + final String accessToken = accessTokenProvider.generateJwtToken(user); + final String refreshToken = refreshTokenProvider.generateJwtToken(user); + + refreshTokenProvider.cacheToken(user.getId(), refreshToken); + + return Token.of(JwtTokenProvider.BEARER_TYPE, accessToken, refreshToken, + accessTokenProvider.expireTime, refreshTokenProvider.expireTime); + } + + public Token reissueAccessToken(final String refreshTokenString) { + if (refreshTokenString == null) { + throw new CustomRuntimeException(ErrorCode.NOT_FOUND_REFRESH_TOKEN); + } + + final Authentication authentication = refreshTokenProvider.getAuthentication( + refreshTokenString); + final PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); + final UserJpaEntity user = principalDetails.user(); + + refreshTokenProvider.invalidateToken(user.getId()); + + return generateAuthToken(user); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/auth/JwtTokenService.java b/src/main/java/life/mosu/mosuserver/application/auth/provider/JwtTokenProvider.java similarity index 74% rename from src/main/java/life/mosu/mosuserver/application/auth/JwtTokenService.java rename to src/main/java/life/mosu/mosuserver/application/auth/provider/JwtTokenProvider.java index bc90a54a..a5c98f9b 100644 --- a/src/main/java/life/mosu/mosuserver/application/auth/JwtTokenService.java +++ b/src/main/java/life/mosu/mosuserver/application/auth/provider/JwtTokenProvider.java @@ -1,4 +1,4 @@ -package life.mosu.mosuserver.application.auth; +package life.mosu.mosuserver.application.auth.provider; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; @@ -7,9 +7,9 @@ import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; -import jakarta.servlet.http.HttpServletRequest; import java.security.Key; import java.util.Date; +import life.mosu.mosuserver.application.auth.PrincipalDetails; import life.mosu.mosuserver.domain.user.UserJpaEntity; import life.mosu.mosuserver.domain.user.UserJpaRepository; import life.mosu.mosuserver.global.exception.CustomRuntimeException; @@ -19,7 +19,7 @@ import org.springframework.security.core.Authentication; @Slf4j -public abstract class JwtTokenService { +public abstract class JwtTokenProvider { protected static final String TOKEN_TYPE_KEY = "type"; protected static final String BEARER_TYPE = "Bearer"; @@ -30,18 +30,18 @@ public abstract class JwtTokenService { protected final String header; protected final UserJpaRepository userRepository; - protected JwtTokenService( + protected JwtTokenProvider( final Long expireTime, final String secretKey, final String tokenType, final String header, - final UserJpaRepository userJpaRepository + final UserJpaRepository userRepository ) { this.expireTime = expireTime; this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)); this.tokenType = tokenType; this.header = header; - this.userRepository = userJpaRepository; + this.userRepository = userRepository; } /** @@ -62,24 +62,6 @@ public String generateJwtToken(final UserJpaEntity user) { .compact(); } - /** - * JWT 토큰을 생성한다. - * - * @param user 토큰을 생성할 회원 - * @return 생성된 토큰 - */ - public String generateAccessToken(final UserJpaEntity user) { - final long now = System.currentTimeMillis(); - final Date expireTime = new Date(now + this.expireTime); - - return Jwts.builder() - .setSubject(user.getLoginId()) - .claim(TOKEN_TYPE_KEY, tokenType) - .setExpiration(expireTime) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); - } - /** * JWT 토큰을 파싱하여 Authentication 객체를 생성한다. * @@ -131,19 +113,4 @@ protected Claims validateAndParseToken(final String token) { throw new CustomRuntimeException(ErrorCode.INVALID_TOKEN_TYPE); } } - - /** - * HttpServletRequest에서 토큰을 추출한다. 토큰이 없는 경우 null을 반환한다. - * - * @param request HttpServletRequest - * @return 추출된 토큰 - */ - public String resolveToken(final HttpServletRequest request) { - final String header = request.getHeader(this.header); - - if (header != null && header.startsWith(BEARER_TYPE)) { - return header.replace(BEARER_TYPE, "").trim(); - } - return null; - } } diff --git a/src/main/java/life/mosu/mosuserver/application/auth/provider/OneTimeTokenProvider.java b/src/main/java/life/mosu/mosuserver/application/auth/provider/OneTimeTokenProvider.java new file mode 100644 index 00000000..814d5a67 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/auth/provider/OneTimeTokenProvider.java @@ -0,0 +1,66 @@ +package life.mosu.mosuserver.application.auth.provider; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.util.Date; +import life.mosu.mosuserver.domain.auth.signup.SignUpToken; +import life.mosu.mosuserver.domain.auth.signup.SignUpTokenRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class OneTimeTokenProvider { + private final SignUpTokenRepository signUpTokenRepository; + private static final String tokenType = "ONE_TIME"; + private static final String TOKEN_TYPE_KEY = "type"; + + private final Long expireTime; + private final Key key; + + public OneTimeTokenProvider( + @Value("${jwt.access-token.expire-time}") final Long expireTime, + @Value("${jwt.secret}") final String secretKey, + SignUpTokenRepository signUpTokenRepository + ) { + this.expireTime = expireTime; + this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)); + this.signUpTokenRepository = signUpTokenRepository; + } + + public String generateOneTimeToken(String subject) { + final long now = System.currentTimeMillis(); + final Date expireTime = new Date(now + this.expireTime); + + return Jwts.builder() + .setSubject(subject) + .claim(TOKEN_TYPE_KEY, tokenType) + .setExpiration(expireTime) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public SignUpToken getSignUpToken(String token) { + Claims claims = parseClaims(token); + String certNum = claims.getSubject(); + + return signUpTokenRepository.findByCertNum(certNum); + } + + private Claims parseClaims(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (ExpiredJwtException exception) { + return exception.getClaims(); + } + + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/auth/RefreshTokenService.java b/src/main/java/life/mosu/mosuserver/application/auth/provider/RefreshTokenProvider.java similarity index 69% rename from src/main/java/life/mosu/mosuserver/application/auth/RefreshTokenService.java rename to src/main/java/life/mosu/mosuserver/application/auth/provider/RefreshTokenProvider.java index 58fcde33..4635e687 100644 --- a/src/main/java/life/mosu/mosuserver/application/auth/RefreshTokenService.java +++ b/src/main/java/life/mosu/mosuserver/application/auth/provider/RefreshTokenProvider.java @@ -1,28 +1,28 @@ -package life.mosu.mosuserver.application.auth; +package life.mosu.mosuserver.application.auth.provider; import io.jsonwebtoken.Claims; -import life.mosu.mosuserver.domain.auth.security.RefreshToken; -import life.mosu.mosuserver.domain.auth.security.RefreshTokenRepository; +import life.mosu.mosuserver.domain.auth.refresh.RefreshToken; +import life.mosu.mosuserver.domain.auth.refresh.RefreshTokenRepository; import life.mosu.mosuserver.domain.user.UserJpaRepository; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Service -public class RefreshTokenService extends JwtTokenService { +public class RefreshTokenProvider extends JwtTokenProvider { private final RefreshTokenRepository refreshTokenRepository; + private static final String TOKEN_TYPE = "Refresh"; + private static final String HEADER = "Refresh-Token"; - @Autowired - public RefreshTokenService( + public RefreshTokenProvider( @Value("${jwt.refresh-token.expire-time}") final Long expireTime, @Value("${jwt.secret}") final String secretKey, final UserJpaRepository userJpaRepository, final RefreshTokenRepository refreshTokenRepository ) { - super(expireTime, secretKey, "Refresh", "Refresh-Token", userJpaRepository); + super(expireTime, secretKey, TOKEN_TYPE, HEADER, userJpaRepository); this.refreshTokenRepository = refreshTokenRepository; } @@ -34,14 +34,14 @@ protected Claims validateAndParseToken(final String token) { return super.validateAndParseToken(token); } - public void deleteRefreshToken(final Long id) { + public void invalidateToken(final Long id) { if (!refreshTokenRepository.existsByUserId(id)) { throw new CustomRuntimeException(ErrorCode.NOT_EXIST_REFRESH_TOKEN); } refreshTokenRepository.deleteByUserId(id); } - public void cacheRefreshToken(final Long userId, final String refreshToken) { + public void cacheToken(final Long userId, final String refreshToken) { refreshTokenRepository.save( RefreshToken.of( userId, diff --git a/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserService.java b/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserService.java index f4a92b81..feda802c 100644 --- a/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserService.java +++ b/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserService.java @@ -60,7 +60,7 @@ private UserJpaEntity updateOrWrite(final OAuthUserInfo info) { .name(info.name()) .birth(info.birthDay() != null ? info.birthDay() : LocalDate.of(1900, 1, 1)) - .userRole(UserRole.ROLE_PENDING) + .userRole(UserRole.ROLE_USER) .provider(AuthProvider.KAKAO) .agreedToTermsOfService(true) .agreedToPrivacyPolicy(true) diff --git a/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEventListener.java b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEventListener.java index f56c1468..ad4beb95 100644 --- a/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEventListener.java +++ b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEventListener.java @@ -9,10 +9,10 @@ import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; -@Transactional(propagation = Propagation.NOT_SUPPORTED) -@Component @Slf4j +@Component @RequiredArgsConstructor +@Transactional(propagation = Propagation.NOT_SUPPORTED) public class PaymentTxEventListener { private final TxFailureHandler paymentFailureHandler; diff --git a/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxFailureHandler.java b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxFailureHandler.java index 0c194654..e389887b 100644 --- a/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxFailureHandler.java +++ b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxFailureHandler.java @@ -11,8 +11,8 @@ import org.springframework.stereotype.Component; @Slf4j -@RequiredArgsConstructor @Component +@RequiredArgsConstructor public class PaymentTxFailureHandler implements TxFailureHandler { diff --git a/src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshToken.java b/src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshToken.java similarity index 83% rename from src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshToken.java rename to src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshToken.java index 2e15192f..aaff4625 100644 --- a/src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshToken.java +++ b/src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshToken.java @@ -1,4 +1,4 @@ -package life.mosu.mosuserver.domain.auth.security; +package life.mosu.mosuserver.domain.auth.refresh; public record RefreshToken( Long userId, diff --git a/src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshTokenKeyValueRepository.java b/src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshTokenKeyValueRepository.java similarity index 82% rename from src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshTokenKeyValueRepository.java rename to src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshTokenKeyValueRepository.java index 272aa9ea..a8261244 100644 --- a/src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshTokenKeyValueRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshTokenKeyValueRepository.java @@ -1,4 +1,4 @@ -package life.mosu.mosuserver.domain.auth.security; +package life.mosu.mosuserver.domain.auth.refresh; import org.springframework.data.keyvalue.repository.KeyValueRepository; diff --git a/src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshTokenRedisEntity.java b/src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshTokenRedisEntity.java similarity index 94% rename from src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshTokenRedisEntity.java rename to src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshTokenRedisEntity.java index 6587b20e..550effd5 100644 --- a/src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshTokenRedisEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshTokenRedisEntity.java @@ -1,4 +1,4 @@ -package life.mosu.mosuserver.domain.auth.security; +package life.mosu.mosuserver.domain.auth.refresh; import lombok.AllArgsConstructor; import lombok.NoArgsConstructor; diff --git a/src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshTokenRedisRepository.java b/src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshTokenRedisRepository.java similarity index 84% rename from src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshTokenRedisRepository.java rename to src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshTokenRedisRepository.java index fe0399d6..b275f11b 100644 --- a/src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshTokenRedisRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshTokenRedisRepository.java @@ -1,4 +1,4 @@ -package life.mosu.mosuserver.domain.auth.security; +package life.mosu.mosuserver.domain.auth.refresh; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -16,7 +16,7 @@ public boolean existsByRefreshToken(final String refreshToken) { @Override public void save(final RefreshToken refreshToken) { - final RefreshTokenRedisEntity entity = RefreshTokenRedisEntity.from(refreshToken); + RefreshTokenRedisEntity entity = RefreshTokenRedisEntity.from(refreshToken); repository.save(entity); } diff --git a/src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshTokenRepository.java b/src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshTokenRepository.java similarity index 80% rename from src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshTokenRepository.java rename to src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshTokenRepository.java index 709b03d0..08f71777 100644 --- a/src/main/java/life/mosu/mosuserver/domain/auth/security/RefreshTokenRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/auth/refresh/RefreshTokenRepository.java @@ -1,4 +1,4 @@ -package life.mosu.mosuserver.domain.auth.security; +package life.mosu.mosuserver.domain.auth.refresh; public interface RefreshTokenRepository { boolean existsByRefreshToken(String refreshToken); diff --git a/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpToken.java b/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpToken.java new file mode 100644 index 00000000..d9d26c1e --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpToken.java @@ -0,0 +1,17 @@ +package life.mosu.mosuserver.domain.auth.signup; + +public record SignUpToken( + String certNum, + Long expiration +) { + public static SignUpToken of(String certNum, Long expiration) { + return new SignUpToken(certNum, expiration); + } + + public static SignUpToken from(SignUpTokenRedisEntity signUpTokenRedisEntity) { + return new SignUpToken( + signUpTokenRedisEntity.getCertNum(), + signUpTokenRedisEntity.getExpiration() + ); + } +} diff --git a/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpTokenKeyValueRepository.java b/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpTokenKeyValueRepository.java new file mode 100644 index 00000000..88dfe9ac --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpTokenKeyValueRepository.java @@ -0,0 +1,7 @@ +package life.mosu.mosuserver.domain.auth.signup; + +import org.springframework.data.keyvalue.repository.KeyValueRepository; + +public interface SignUpTokenKeyValueRepository extends + KeyValueRepository { +} diff --git a/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpTokenRedisEntity.java b/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpTokenRedisEntity.java new file mode 100644 index 00000000..c2343080 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpTokenRedisEntity.java @@ -0,0 +1,28 @@ +package life.mosu.mosuserver.domain.auth.signup; + +import java.util.concurrent.TimeUnit; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@RedisHash(value = "sign_up_verification") +@AllArgsConstructor(access = lombok.AccessLevel.PRIVATE) +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class SignUpTokenRedisEntity { + + @Id + @Indexed + private String certNum; + + @TimeToLive(unit = TimeUnit.MILLISECONDS) + private Long expiration; + + public static SignUpTokenRedisEntity from(final SignUpToken token) { + return new SignUpTokenRedisEntity(token.certNum(), token.expiration()); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpTokenRedisRepository.java b/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpTokenRedisRepository.java new file mode 100644 index 00000000..3922cf5a --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpTokenRedisRepository.java @@ -0,0 +1,32 @@ +package life.mosu.mosuserver.domain.auth.signup; + +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import life.mosu.mosuserver.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class SignUpTokenRedisRepository implements SignUpTokenRepository { + + private final SignUpTokenKeyValueRepository repository; + + @Override + public void save(SignUpToken signUpToken) { + SignUpTokenRedisEntity entity = SignUpTokenRedisEntity.from(signUpToken); + repository.save(entity); + } + + @Override + public SignUpToken findByCertNum(String certNum) { + SignUpTokenRedisEntity signUpTokenRedisEntity = repository.findById(certNum).orElseThrow( + () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_SIGN_UP_TOKEN) + ); + return SignUpToken.from(signUpTokenRedisEntity); + } + + @Override + public void deleteByCertNum(String certNum) { + repository.deleteById(certNum); + } +} diff --git a/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpTokenRepository.java b/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpTokenRepository.java new file mode 100644 index 00000000..ef323135 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/auth/signup/SignUpTokenRepository.java @@ -0,0 +1,9 @@ +package life.mosu.mosuserver.domain.auth.signup; + +public interface SignUpTokenRepository { + SignUpToken findByCertNum(String certNum); + + void save(SignUpToken signUpToken); + + void deleteByCertNum(String certNum); +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/domain/user/UserRole.java b/src/main/java/life/mosu/mosuserver/domain/user/UserRole.java index 969db060..0e564afa 100644 --- a/src/main/java/life/mosu/mosuserver/domain/user/UserRole.java +++ b/src/main/java/life/mosu/mosuserver/domain/user/UserRole.java @@ -1,5 +1,5 @@ package life.mosu.mosuserver.domain.user; public enum UserRole { - ROLE_USER, ROLE_ADMIN, ROLE_PENDING; + ROLE_USER, ROLE_ADMIN, PENDING } diff --git a/src/main/java/life/mosu/mosuserver/domain/user/service/UserEncoderService.java b/src/main/java/life/mosu/mosuserver/domain/user/service/UserEncoderService.java new file mode 100644 index 00000000..af5a1521 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/user/service/UserEncoderService.java @@ -0,0 +1,14 @@ +package life.mosu.mosuserver.domain.user.service; + +import life.mosu.mosuserver.global.annotation.DomainService; +import org.springframework.security.crypto.password.PasswordEncoder; + +@DomainService +public class UserEncoderService { + + private PasswordEncoder encoder; + + public void e(String a) { + System.out.println("Encoding: " + a); + } +} diff --git a/src/main/java/life/mosu/mosuserver/global/annotation/DomainService.java b/src/main/java/life/mosu/mosuserver/global/annotation/DomainService.java new file mode 100644 index 00000000..b5053327 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/annotation/DomainService.java @@ -0,0 +1,22 @@ +package life.mosu.mosuserver.global.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.core.annotation.AliasFor; +import org.springframework.stereotype.Component; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Component +public @interface DomainService { + + /** + * Alias for {@link Component#value}. + */ + @AliasFor(annotation = Component.class) + String value() default ""; +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/global/config/KmcConfig.java b/src/main/java/life/mosu/mosuserver/global/config/KmcConfig.java new file mode 100644 index 00000000..b02fffbc --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/config/KmcConfig.java @@ -0,0 +1,13 @@ +package life.mosu.mosuserver.global.config; +import com.icert.comm.secu.IcertSecuManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class KmcConfig { + + @Bean + public IcertSecuManager icertSecuManager() { + return new IcertSecuManager(); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java b/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java index 0a662273..3c820a6e 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java @@ -12,6 +12,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; 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; @@ -72,7 +73,7 @@ public WebSecurityCustomizer webSecurityCustomizer() { public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) // .cors(Customizer.withDefaults()) - .cors(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) .httpBasic(AbstractHttpConfigurer::disable) .headers(c -> c.frameOptions(FrameOptionsConfig::disable)) .sessionManagement( diff --git a/src/main/java/life/mosu/mosuserver/global/exception/AuthenticationException.java b/src/main/java/life/mosu/mosuserver/global/exception/AuthenticationException.java new file mode 100644 index 00000000..4c7165f4 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/exception/AuthenticationException.java @@ -0,0 +1,19 @@ +package life.mosu.mosuserver.global.exception; + +import lombok.Getter; + +@Getter +public class AuthenticationException extends RuntimeException { + + private final String loginId; + + public AuthenticationException(String message, String loginId) { + super(message); + this.loginId = loginId; + } + + public AuthenticationException(String message) { + super(message); + this.loginId = null; + } +} diff --git a/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java b/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java index cc4a278b..4819ad7e 100644 --- a/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java +++ b/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java @@ -24,6 +24,8 @@ public enum ErrorCode { INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 리프레시 토큰입니다."), NOT_FOUND_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "리프레시 토큰을 찾을 수 없습니다."), NOT_EXIST_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 존재하지 않습니다."), + VERIFICATION_FAILED(HttpStatus.UNAUTHORIZED, "인증에 실패했습니다."), + NOT_FOUND_SIGN_UP_TOKEN(HttpStatus.NOT_FOUND, "회원가입 인증 토큰을 찾을 수 없습니다."), // 유저 관련 에러 USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 사용자입니다."), diff --git a/src/main/java/life/mosu/mosuserver/global/handler/OAuth2LoginSuccessHandler.java b/src/main/java/life/mosu/mosuserver/global/handler/OAuth2LoginSuccessHandler.java index d57c9a2c..8f5387c5 100644 --- a/src/main/java/life/mosu/mosuserver/global/handler/OAuth2LoginSuccessHandler.java +++ b/src/main/java/life/mosu/mosuserver/global/handler/OAuth2LoginSuccessHandler.java @@ -4,7 +4,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import life.mosu.mosuserver.application.auth.AuthTokenManager; +import life.mosu.mosuserver.application.auth.provider.AuthTokenManager; import life.mosu.mosuserver.application.oauth.OAuthUser; import life.mosu.mosuserver.presentation.auth.dto.LoginResponse; import life.mosu.mosuserver.presentation.auth.dto.Token; diff --git a/src/main/java/life/mosu/mosuserver/global/resolver/UserIdArgumentResolver.java b/src/main/java/life/mosu/mosuserver/global/resolver/UserIdArgumentResolver.java index aaecdf61..3b4675ef 100644 --- a/src/main/java/life/mosu/mosuserver/global/resolver/UserIdArgumentResolver.java +++ b/src/main/java/life/mosu/mosuserver/global/resolver/UserIdArgumentResolver.java @@ -1,11 +1,11 @@ package life.mosu.mosuserver.global.resolver; -import life.mosu.mosuserver.application.auth.AccessTokenService; import life.mosu.mosuserver.application.auth.PrincipalDetails; import life.mosu.mosuserver.global.annotation.UserId; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.core.MethodParameter; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -15,12 +15,11 @@ import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; +@Slf4j @Component @RequiredArgsConstructor public class UserIdArgumentResolver implements HandlerMethodArgumentResolver { - private final AccessTokenService accessTokenService; - @Override public boolean supportsParameter(final MethodParameter parameter) { return parameter.hasParameterAnnotation(UserId.class) && parameter.getParameterType() @@ -39,7 +38,7 @@ public Object resolveArgument(final MethodParameter parameter, } PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal(); - + log.info("principalID: {}", principal.getId()); return principal.getId(); } } diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/KmcAuthController.java b/src/main/java/life/mosu/mosuserver/infra/kmc/KmcAuthController.java new file mode 100644 index 00000000..9453d9c7 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/KmcAuthController.java @@ -0,0 +1,54 @@ +package life.mosu.mosuserver.infra.kmc; + +import life.mosu.mosuserver.global.util.ApiResponseWrapper; +import life.mosu.mosuserver.infra.kmc.dto.KmcCertRequest; +import life.mosu.mosuserver.infra.kmc.dto.KmcCertResponse; +import life.mosu.mosuserver.infra.kmc.dto.KmcResultCallbackRequest; +import life.mosu.mosuserver.infra.kmc.dto.KmcResultCallbackResponse; +import life.mosu.mosuserver.infra.kmc.dto.KmcUserInfo; +import life.mosu.mosuserver.infra.kmc.dto.KmcVerificationRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +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; + +@Slf4j +@RestController +@RequestMapping("/kmc") +@RequiredArgsConstructor +public class KmcAuthController { + + private final KmcService kmcService; + + @PostMapping("/confirm") + public ResponseEntity> handleKmcResult( + @RequestBody KmcCertRequest request + ) { + log.info("STEP1 - KMC 본인인증 요청: {}", request.serviceTerm()); + KmcCertResponse response = kmcService.createCertRequest(request); + return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "성공", response)); + } + + @PostMapping("/decrypt") + public ResponseEntity> tokenDecrypt( + @RequestBody KmcResultCallbackRequest request + ) { + log.info("STEP2 - KMC 본인인증 요청: {}", request.apiToken()); + KmcResultCallbackResponse response = kmcService.tokenDecrypt(request.apiToken()); + return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "성공", response)); + } + + @PostMapping("/verification") + public ResponseEntity> decryptKmcInitData( + @RequestBody KmcVerificationRequest request + ) { + log.info("STEP3 - KMC 본인인증 요청: {}", request.apiCertNum()); + log.info("STEP3 - KMC 본인인증 요청: {}", request.apiRecCert()); + KmcUserInfo response = kmcService.processVerificationResult(request); + return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "성공", response)); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/KmcCryptoManager.java b/src/main/java/life/mosu/mosuserver/infra/kmc/KmcCryptoManager.java new file mode 100644 index 00000000..23efd5df --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/KmcCryptoManager.java @@ -0,0 +1,76 @@ +package life.mosu.mosuserver.infra.kmc; + +import com.icert.comm.secu.IcertSecuManager; +import life.mosu.mosuserver.infra.kmc.dto.KmcCertRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import java.text.SimpleDateFormat; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class KmcCryptoManager { + + private final KmcProperties kmcProperties; + private final IcertSecuManager secuManager; + + private static final String EXTEND_VAR = "0000000000000000"; + private static final String DELIMITER = "/"; + + /** + * KMC 본인인증 요청 데이터(tr_cert)를 암호화 + */ + public String encryptRequestData(KmcCertRequest request, String certNum) { + String currentDate = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); + String plusInfo = request.serviceTerm(); + + String rawData = String.join(DELIMITER, + kmcProperties.getCpId(), kmcProperties.getUrlCode(), certNum, currentDate, "M", + "", "", "", "", "", "", + plusInfo, EXTEND_VAR + ); + log.info("Raw tr_cert data for encryption: {}", rawData); + + String enc_tr_cert_1 = secuManager.getEnc(rawData, ""); + String hmacMsg = secuManager.getMsg(enc_tr_cert_1); + return secuManager.getEnc(enc_tr_cert_1 + DELIMITER + hmacMsg + DELIMITER + EXTEND_VAR, ""); + } + + /** + * KMC 응답 데이터(rec_cert)를 복호화하고 무결성을 검증 + * @return 2차 복호화까지 완료된 최종 사용자 정보 문자열 + */ + public String decryptResponseData(String recCert) { + try { + // 1차 복호화 + String firstDecrypted = decrypt(recCert); + + // 데이터와 HMAC(무결성 검증 값) 분리 + int firstIdx = firstDecrypted.indexOf(DELIMITER); + String encPara = firstDecrypted.substring(0, firstIdx); + String receivedHmac = firstDecrypted.substring(firstIdx + 1, firstDecrypted.lastIndexOf(DELIMITER)); + + // 무결성 검증 + String generatedHmac = secuManager.getMsg(encPara); + if (!generatedHmac.equals(receivedHmac)) { + throw new SecurityException("KMC 데이터의 위변조가 의심됩니다."); + } + + // 2차 복호화하여 최종 데이터 반환 + return decrypt(encPara); + } catch (Exception e) { + throw new RuntimeException("KMC 인증 결과를 처리하는 중 오류가 발생했습니다.", e); + } + } + + /** + * KMC에서 받은 암호화된 데이터를 복호화 + * @param encryptedData KMC로부터 받은 암호화된 데이터 + * @return 복호화된 문자열 + */ + public String decrypt(String encryptedData) { + return secuManager.getDec(encryptedData, ""); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/KmcDataMapper.java b/src/main/java/life/mosu/mosuserver/infra/kmc/KmcDataMapper.java new file mode 100644 index 00000000..a4c1be54 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/KmcDataMapper.java @@ -0,0 +1,105 @@ +package life.mosu.mosuserver.infra.kmc; + +import life.mosu.mosuserver.application.auth.provider.OneTimeTokenProvider; +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import life.mosu.mosuserver.global.exception.ErrorCode; +import life.mosu.mosuserver.infra.kmc.dto.KmcUserInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.binary.Base32; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + + @Slf4j + @Component + @RequiredArgsConstructor + public class KmcDataMapper { + + private final KmcCryptoManager kmcCryptoManager; + private final OneTimeTokenProvider tokenProvider; + + private static final String DELIMITER = "/"; + private static final int MIN_FIELD_COUNT = 18; + + // KMC 응답 필드 인덱스를 상수로 정의하여 매직 넘버 제거 + private static final int CERT_NUM_INDEX = 0; + private static final int DATE_INDEX = 1; + private static final int CI_INDEX = 2; + private static final int PHONE_NO_INDEX = 3; + private static final int PHONE_CORP_INDEX = 4; + private static final int BIRTH_INDEX = 5; + private static final int GENDER_INDEX = 6; + private static final int NAME_INDEX = 8; + private static final int RESULT_SUCCESS_INDEX = 9; // 성공여부 + private static final int PLUS_INFO_INDEX = 16; + private static final int DI_INDEX = 17; + + /** + * 복호화된 KMC 최종 데이터 문자열을 KmcUserInfo DTO로 변환합니다. + */ + public KmcUserInfo mapToUserInfo(String finalDecryptedData) { + String[] tokens = finalDecryptedData.split(DELIMITER, -1); + if (tokens.length < MIN_FIELD_COUNT) { + throw new CustomRuntimeException(ErrorCode.INVALID_TOKEN); + } + + if (!tokens[RESULT_SUCCESS_INDEX].equals("Y")) { + throw new CustomRuntimeException(ErrorCode.VERIFICATION_FAILED); + } + + logDecryptedData(tokens); + + String name = tokens[NAME_INDEX]; + String birth = tokens[BIRTH_INDEX]; + String phoneNo = tokens[PHONE_NO_INDEX]; + String gender = tokens[GENDER_INDEX]; + + String servieTerm = tokens[PLUS_INFO_INDEX]; + String signUpToken = tokenProvider.generateOneTimeToken(tokens[CERT_NUM_INDEX]); + + return KmcUserInfo.of(name, birth, phoneNo, gender, servieTerm, signUpToken); + } + + // production 시 삭제 + private void logDecryptedData(String[] tokens) { + if (!log.isInfoEnabled()) return; + + String ci = decryptCiDiValue(tokens[CI_INDEX]); + String di = decryptCiDiValue(tokens[DI_INDEX]); + + log.info("[KMCIS] Parsed User Information:"); + log.info(" - CertNum: {}", tokens[CERT_NUM_INDEX]); + log.info(" - Name: {}", tokens[NAME_INDEX]); + log.info(" - PhoneNo: {}", tokens[PHONE_NO_INDEX]); + log.info(" - Decrypted CI: {}", ci); + log.info(" - Decrypted DI: {}", di); + log.info(" - PlusInfo: {}", tokens[PLUS_INFO_INDEX]); + + logPlusInfoDetail(tokens[PLUS_INFO_INDEX]); + } + + // production 시 삭제 + private void logPlusInfoDetail(String plusInfo) { + if (!StringUtils.hasText(plusInfo) || !plusInfo.contains("@")) return; + + String[] parts = plusInfo.split("@", -1); + if (parts.length == 3) { + byte[] decodedBytes = new Base32().decode(parts[1]); + String decodedPassword = new String(decodedBytes); + log.info(" - Parsed PlusInfo -> loginId: {}, password(decoded): {}, term: {}", parts[0], decodedPassword, parts[2]); + } + } + + // CI/DI 복호화 유틸리티 + private String decryptCiDiValue(String encryptedValue) { + if (StringUtils.hasText(encryptedValue)) { + try { + return kmcCryptoManager.decrypt(encryptedValue); + } catch (Exception e) { + log.warn("Failed to decrypt CI/DI value.", e); + return "DECRYPTION_FAILED"; + } + } + return "EMPTY"; + } + } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/KmcProperties.java b/src/main/java/life/mosu/mosuserver/infra/kmc/KmcProperties.java new file mode 100644 index 00000000..4220465d --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/KmcProperties.java @@ -0,0 +1,14 @@ +package life.mosu.mosuserver.infra.kmc; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "kmc") +public class KmcProperties { + private String cpId; + private String urlCode; + private Long expireTime; +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/KmcService.java b/src/main/java/life/mosu/mosuserver/infra/kmc/KmcService.java new file mode 100644 index 00000000..3cbcdfa0 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/KmcService.java @@ -0,0 +1,62 @@ +package life.mosu.mosuserver.infra.kmc; + +import life.mosu.mosuserver.application.auth.kmc.KmcEventTxService; +import life.mosu.mosuserver.infra.kmc.dto.KmcCertRequest; +import life.mosu.mosuserver.infra.kmc.dto.KmcUserInfo; +import life.mosu.mosuserver.infra.kmc.dto.KmcCertResponse; +import life.mosu.mosuserver.infra.kmc.dto.KmcResultCallbackResponse; +import life.mosu.mosuserver.infra.kmc.dto.KmcVerificationRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KmcService { + + private final KmcEventTxService eventTxService; + private final KmcProperties kmcProperties; + private final KmcCryptoManager kmcCryptoManager; + private final KmcDataMapper kmcDataMapper; + + /** + * KMC 본인인증 요청 데이터를 생성합니다. + */ + public KmcCertResponse createCertRequest(KmcCertRequest request) { + String certNum = UUID.randomUUID().toString().replace("-", ""); + + try { + // 암호화 작업 + String encryptedTrCert = kmcCryptoManager.encryptRequestData(request, certNum); + + // 레디스에 저장하기 + eventTxService.publishIssueEvent(certNum, kmcProperties.getExpireTime()); + + return KmcCertResponse.from(encryptedTrCert); + } catch (Exception ex) { + log.error("Failed to create KMC verification request for certNum: {}",certNum, ex); + eventTxService.publishFailureEvent(certNum); + throw new RuntimeException("KMC 인증 요청 생성에 실패했습니다.", ex); + } + } + + /** + * 수신된 최종 암호화 데이터(recCert)를 처리하여 사용자 정보를 반환합니다. + */ + public KmcUserInfo processVerificationResult(KmcVerificationRequest request) { + + String finalDecryptedData = kmcCryptoManager.decryptResponseData(request.apiRecCert()); + + return kmcDataMapper.mapToUserInfo(finalDecryptedData); + } + + /** + * KMC Api Token을 복호화하는 메소드 (CryptoManager에 위임) + */ + public KmcResultCallbackResponse tokenDecrypt(String encryptedToken) { + String decryptedToken = kmcCryptoManager.decrypt(encryptedToken); + return new KmcResultCallbackResponse(decryptedToken); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcApiRequest.java b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcApiRequest.java new file mode 100644 index 00000000..bf68ba83 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcApiRequest.java @@ -0,0 +1,6 @@ +package life.mosu.mosuserver.infra.kmc.dto; + +public record KmcApiRequest( + String apiToken, + String apiDate +) {} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcCertRequest.java b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcCertRequest.java new file mode 100644 index 00000000..2e1fed3f --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcCertRequest.java @@ -0,0 +1,7 @@ +package life.mosu.mosuserver.infra.kmc.dto; + +public record KmcCertRequest( + String serviceTerm +) { + +} diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcCertResponse.java b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcCertResponse.java new file mode 100644 index 00000000..8b0f63bb --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcCertResponse.java @@ -0,0 +1,10 @@ +package life.mosu.mosuserver.infra.kmc.dto; + +public record KmcCertResponse( + String tr_cert +) { + + public static KmcCertResponse from(String trCert) { + return new KmcCertResponse(trCert); + } +} diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcResultCallbackRequest.java b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcResultCallbackRequest.java new file mode 100644 index 00000000..8bb5a7ab --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcResultCallbackRequest.java @@ -0,0 +1,7 @@ +package life.mosu.mosuserver.infra.kmc.dto; + +public record KmcResultCallbackRequest( + String apiToken +) { + +} diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcResultCallbackResponse.java b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcResultCallbackResponse.java new file mode 100644 index 00000000..1625264a --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcResultCallbackResponse.java @@ -0,0 +1,7 @@ +package life.mosu.mosuserver.infra.kmc.dto; + +public record KmcResultCallbackResponse( + String token +) { + +} diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcUserInfo.java b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcUserInfo.java new file mode 100644 index 00000000..ef485755 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcUserInfo.java @@ -0,0 +1,30 @@ +package life.mosu.mosuserver.infra.kmc.dto; + +import java.util.Objects; + +public record KmcUserInfo( + String name, + String birth, + String phoneNumber, + String gender, + String serviceTerm, + String signUpToken +) { + public static KmcUserInfo of( + String name, + String birth, + String phoneNumber, + String gender, + String serviceTerm, + String signUpToken + ) { + return new KmcUserInfo( + name, + birth, + phoneNumber, + Objects.equals(gender, "0") ? "남자" : "여자", + serviceTerm, + signUpToken + ); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcVerificationRequest.java b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcVerificationRequest.java new file mode 100644 index 00000000..7f7f3060 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/dto/KmcVerificationRequest.java @@ -0,0 +1,7 @@ +package life.mosu.mosuserver.infra.kmc.dto; + +public record KmcVerificationRequest( + String resultCode, + String apiRecCert, + String apiCertNum +) {} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/legacy/KmcMvcController.java b/src/main/java/life/mosu/mosuserver/infra/kmc/legacy/KmcMvcController.java new file mode 100644 index 00000000..5cc675d5 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/legacy/KmcMvcController.java @@ -0,0 +1,65 @@ +package life.mosu.mosuserver.infra.kmc.legacy; + +import life.mosu.mosuserver.infra.kmc.KmcProperties; +import life.mosu.mosuserver.infra.kmc.KmcService; +import life.mosu.mosuserver.infra.kmc.dto.KmcCertRequest; +import life.mosu.mosuserver.infra.kmc.dto.KmcUserInfo; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Slf4j +@Controller +@RequestMapping("/kmc") // URL 경로를 /kmc로 통일 +@RequiredArgsConstructor +public class KmcMvcController { + + private final LegacyService legacyService; + private final KmcService kmcService; + + /** + * 인증 요청 페이지를 렌더링하고, KMC로 보낼 암호화 데이터를 생성 + */ + @GetMapping("/req") + public String getKmcPage(Model model) { + // 서비스 로직을 통해 암호화된 tr_cert 생성 + + KmcCertRequest request = new KmcCertRequest( + "TTF"); + String cert = kmcService.createCertRequest(request).tr_cert(); + + model.addAttribute("tr_cert", cert); + model.addAttribute("tr_url", "http://localhost:8080/api/v1/kmc/tr-url"); + model.addAttribute("tr_ver", "V2"); + + return "step2"; // step2.jsp 렌더링 + } + + /** + * KMC가 tr_url로 리디렉션할 때 호출되는 콜백 메소드 + */ + @PostMapping("/tr-url") + public String handleKmcResult( + @RequestParam("apiToken") String apiToken, + @RequestParam("certNum") String apiCertNum, + Model model + ) { + log.info("KMC 콜백 수신 성공! apiToken:{}, apiCertNum:{}", apiToken, apiCertNum); + + try { + // 토큰 검증 및 사용자 정보 조회 로직 + KmcUserInfo userInfo = legacyService.validateTokenAndGetResult(apiToken, apiCertNum); + model.addAttribute("userInfo", userInfo); + return "success"; // 성공 시 보여줄 JSP 페이지 + } catch (Exception e) { + model.addAttribute("errorMessage", e.getMessage()); + return "error"; // 실패 시 보여줄 JSP 페이지 + } + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/kmc/legacy/LegacyService.java b/src/main/java/life/mosu/mosuserver/infra/kmc/legacy/LegacyService.java new file mode 100644 index 00000000..b27b19aa --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/kmc/legacy/LegacyService.java @@ -0,0 +1,72 @@ +package life.mosu.mosuserver.infra.kmc.legacy; + +import com.icert.comm.secu.IcertSecuManager; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import life.mosu.mosuserver.infra.kmc.KmcService; +import life.mosu.mosuserver.infra.kmc.dto.KmcApiRequest; +import life.mosu.mosuserver.infra.kmc.dto.KmcUserInfo; +import life.mosu.mosuserver.infra.kmc.dto.KmcVerificationRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Service +@RequiredArgsConstructor +public class LegacyService { + + private final IcertSecuManager seed; + private final KmcService kmcService; + private final WebClient webClient = WebClient.builder() + .baseUrl("https://www.kmcert.com/kmcis/api") + .build(); + + /** + * KMC 콜백 수신 후, 토큰을 검증하고 최종 사용자 정보를 복호화하여 반환하는 통합 메소드 + * @param encryptedApiToken KMC로부터 받은 암호화된 API 토큰 + * @param apiCertNum KMC로부터 받은 요청 번호 (현재 로직에서는 사용되지 않음) + * @return 복호화된 사용자 정보가 담긴 DTO + */ + public KmcUserInfo validateTokenAndGetResult(String encryptedApiToken, String apiCertNum) { + log.info("[KMCIS] 토큰 검증 및 결과 복호화를 시작합니다. certNum: {}", apiCertNum); + + // 1. KMC 토큰 검증 API 호출 + KmcVerificationRequest apiResponse = callTokenValidationApi(encryptedApiToken); + + // 2. API 응답 코드 확인 + if (!"APR01".equals(apiResponse.resultCode())) { + log.error("❌ KMC 토큰 검증 실패. 응답 코드: {}", apiResponse.resultCode()); + throw new RuntimeException("KMC 본인인증에 실패했습니다. (오류코드: " + apiResponse.resultCode() + ")"); + } + log.info("✅ 토큰 검증 성공 (APR01). 최종 데이터 복호화를 진행합니다."); + + // 3. 최종 데이터(rec_cert) 복호화 + return kmcService.processVerificationResult(apiResponse); + } + + /** + * KMC 토큰 검증 API를 호출하는 내부 메소드 + */ + public KmcVerificationRequest callTokenValidationApi(String encryptedApiToken) { + + String apiToken = seed.getDec(encryptedApiToken, ""); + String apiDate = DateTimeFormatter.ofPattern("yyyyMMddHHmmss").format(LocalDateTime.now()); + KmcApiRequest requestPayload = new KmcApiRequest(apiToken, apiDate); + + try { + return webClient.post() + .uri("/kmcisToken_api.jsp") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(requestPayload) + .retrieve() + .bodyToMono(KmcVerificationRequest.class) + .block(); + } catch (Exception e) { + log.error("❌ 토큰 검증 API 호출 중 예외 발생", e); + throw new RuntimeException("KMC 서버와 통신 중 오류가 발생했습니다.", e); + } + } +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/annotation/RefreshTokenHeader.java b/src/main/java/life/mosu/mosuserver/presentation/admin/annotation/RefreshTokenHeader.java new file mode 100644 index 00000000..542dd354 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/annotation/RefreshTokenHeader.java @@ -0,0 +1,13 @@ +package life.mosu.mosuserver.presentation.admin.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RefreshTokenHeader { +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/AuthController.java b/src/main/java/life/mosu/mosuserver/presentation/auth/AuthController.java index 07f704ea..34e80972 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/auth/AuthController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/AuthController.java @@ -1,9 +1,9 @@ package life.mosu.mosuserver.presentation.auth; -import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import life.mosu.mosuserver.application.auth.AuthService; import life.mosu.mosuserver.global.util.ApiResponseWrapper; +import life.mosu.mosuserver.presentation.admin.annotation.RefreshTokenHeader; import life.mosu.mosuserver.presentation.auth.dto.LoginCommandResponse; import life.mosu.mosuserver.presentation.auth.dto.LoginRequest; import life.mosu.mosuserver.presentation.auth.dto.LoginResponse; @@ -59,16 +59,11 @@ public ResponseEntity> login( LoginResponse.from(command.isProfileRegistered()))); } - /** - * Access Token과 Refresh Token 재발급 - * - * @param servletRequest HttpServletRequest - * @return 재발급 된 Access Token과 Refresh Token - */ + @PostMapping("/reissue") public ResponseEntity> reissueAccessToken( - final HttpServletRequest servletRequest) { - final Token token = authService.reissueAccessToken(servletRequest); + @RefreshTokenHeader final String refreshTokenHeader) { + final Token token = authService.reissueAccessToken(refreshTokenHeader); final String authorization = token.grantType() + " " + token.accessToken(); return ResponseEntity.status(HttpStatus.CREATED) .header(HttpHeaders.AUTHORIZATION, authorization) diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/AuthControllerDocs.java b/src/main/java/life/mosu/mosuserver/presentation/auth/AuthControllerDocs.java index 6f5addd0..7232bd17 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/auth/AuthControllerDocs.java +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/AuthControllerDocs.java @@ -2,9 +2,9 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import life.mosu.mosuserver.global.util.ApiResponseWrapper; +import life.mosu.mosuserver.presentation.admin.annotation.RefreshTokenHeader; import life.mosu.mosuserver.presentation.auth.dto.LoginRequest; import life.mosu.mosuserver.presentation.auth.dto.LoginResponse; import life.mosu.mosuserver.presentation.auth.dto.Token; @@ -20,5 +20,5 @@ public ResponseEntity> login( @Operation(description = "수정될 예정 입니다.") public ResponseEntity> reissueAccessToken( - final HttpServletRequest servletRequest); + @RefreshTokenHeader final String refreshToken); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/SignUpController.java b/src/main/java/life/mosu/mosuserver/presentation/auth/SignUpController.java index 9f99fd6a..ca9b58f3 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/auth/SignUpController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/SignUpController.java @@ -4,7 +4,7 @@ import life.mosu.mosuserver.application.auth.SignUpService; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.global.util.CookieBuilderUtil; -import life.mosu.mosuserver.presentation.auth.dto.SignUpRequest; +import life.mosu.mosuserver.presentation.auth.dto.SignUpAccountRequest; import life.mosu.mosuserver.presentation.auth.dto.Token; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; @@ -20,7 +20,7 @@ @RequiredArgsConstructor public class SignUpController implements SignUpControllerDocs { - private final static String ACCESS_TOKEN_COOKIE_NAME = "refreshToken"; + private final static String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; private final static String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; private final SignUpService signUpService; @@ -31,7 +31,7 @@ public class SignUpController implements SignUpControllerDocs { */ @PostMapping public ResponseEntity> signUp( - @RequestBody @Valid SignUpRequest request + @RequestBody @Valid SignUpAccountRequest request ) { Token token = signUpService.signUp(request); diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/SignUpControllerDocs.java b/src/main/java/life/mosu/mosuserver/presentation/auth/SignUpControllerDocs.java index d63a8f44..81bc8875 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/auth/SignUpControllerDocs.java +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/SignUpControllerDocs.java @@ -4,16 +4,16 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import life.mosu.mosuserver.global.util.ApiResponseWrapper; -import life.mosu.mosuserver.presentation.auth.dto.SignUpRequest; +import life.mosu.mosuserver.presentation.auth.dto.SignUpAccountRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestBody; @Tag(description = "회원가입 API", name = "Sign Up API") public interface SignUpControllerDocs { - @Operation(summary = "회원 가입", description = "사용자가 새로운 계정을 생성합니다.") + @Operation(summary = "회원 가입", description = "step 1, step 2를 모두 받는 회원 가입 API입니다.") public ResponseEntity> signUp( - @RequestBody @Valid final SignUpRequest request + @RequestBody @Valid final SignUpAccountRequest request ); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/dto/SignUpAccountRequest.java b/src/main/java/life/mosu/mosuserver/presentation/auth/dto/SignUpAccountRequest.java index 39711c89..69de016d 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/auth/dto/SignUpAccountRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/dto/SignUpAccountRequest.java @@ -20,7 +20,7 @@ public record SignUpAccountRequest( String id, @Schema( description = "비밀번호는 8~20자의 영문 대/소문자, 숫자, 특수문자를 모두 포함해야 합니다.", - example = "Hello-C-123" + example = "Mosu!1234" ) @PasswordPattern String password, SignUpServiceTermRequest serviceTermRequest @@ -35,7 +35,7 @@ public UserJpaEntity toAuthEntity(PasswordEncoder passwordEncoder) { .agreedToMarketing(serviceTermRequest.agreedToMarketing()) .gender(Gender.PENDING) .provider(AuthProvider.MOSU) - .userRole(UserRole.ROLE_USER) + .userRole(UserRole.PENDING) .build(); } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/dto/SignUpRequest.java b/src/main/java/life/mosu/mosuserver/presentation/auth/dto/SignUpRequest.java index 5edc86b0..ec99ed61 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/auth/dto/SignUpRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/dto/SignUpRequest.java @@ -1,7 +1,15 @@ package life.mosu.mosuserver.presentation.auth.dto; +import io.swagger.v3.oas.annotations.media.Schema; + public record SignUpRequest( + @Schema( + description = "회원 가입 단계 1: 계정 정보" + ) SignUpAccountRequest signUpAccountStep, + @Schema( + description = "회원 가입 단계 2: 프로필 정보" + ) SignUpProfileRequest signUpProfileStep ) { diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/resolver/RefreshTokenHeaderArgumentResolver.java b/src/main/java/life/mosu/mosuserver/presentation/auth/resolver/RefreshTokenHeaderArgumentResolver.java new file mode 100644 index 00000000..052a10f7 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/resolver/RefreshTokenHeaderArgumentResolver.java @@ -0,0 +1,32 @@ +package life.mosu.mosuserver.presentation.auth.resolver; + +import jakarta.servlet.http.HttpServletRequest; +import life.mosu.mosuserver.presentation.admin.annotation.RefreshTokenHeader; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +public class RefreshTokenHeaderArgumentResolver implements HandlerMethodArgumentResolver { + + private static final String HEADER_NAME = "Refresh-Token"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(RefreshTokenHeader.class) + && parameter.getParameterType().equals(String.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + return request.getHeader(HEADER_NAME); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/oauth/AccessTokenFilter.java b/src/main/java/life/mosu/mosuserver/presentation/oauth/AccessTokenFilter.java index b36e1430..0d05ab02 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/oauth/AccessTokenFilter.java +++ b/src/main/java/life/mosu/mosuserver/presentation/oauth/AccessTokenFilter.java @@ -7,7 +7,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Arrays; -import life.mosu.mosuserver.application.auth.AccessTokenService; +import life.mosu.mosuserver.application.auth.provider.AccessTokenProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -21,8 +21,10 @@ @RequiredArgsConstructor public class AccessTokenFilter extends OncePerRequestFilter { - private final AccessTokenService accessTokenService; - + private final static String tokenHeader = "Authorization"; + private static final String BEARER_TYPE = "Bearer"; + private final AccessTokenProvider accessTokenProvider; + private final SignUpTokenService signUpTokenService; @Value("${endpoints.reissue}") private String reissueEndpoint; @@ -32,34 +34,55 @@ protected void doFilterInternal( final HttpServletResponse response, final FilterChain filterChain ) throws ServletException, IOException { + + // token 초기화 if (request.getRequestURI().equals(reissueEndpoint)) { filterChain.doFilter(request, response); return; } -// // API 연동을 위한 필터 비활성화 -// if (request.getRequestURI().startsWith("/api/v1/oauth2") || request.getRequestURI() -// .startsWith("/api/v1/oauth")) { -// filterChain.doFilter(request, response); -// return; -// } - if (request.getRequestURI().startsWith("/api/v1")) { + + // 로그인 토큰 검증 + if (request.getRequestURI().startsWith("/api/v1/auth/login")) { + filterChain.doFilter(request, response); + return; + } + + // 회원가입 토큰 검증 + if (request.getRequestURI().startsWith("/api/v1/auth/signup")) { + log.info("회원가입 토큰 검증 요청: {}", request.getRequestURI()); + validateSignUpToken(resolveToken(request)); filterChain.doFilter(request, response); return; } - final String accessToken = resolveToken(request); + // OAuth2 관련 요청은 토큰 검증을 하지 않음 + if (request.getRequestURI().startsWith("/api/v1/oauth2") || request.getRequestURI() + .startsWith("/api/v1/oauth")) { + log.info("oath2 관련 요청: {}", request.getRequestURI()); + filterChain.doFilter(request, response); + return; + } + + final String accessToken = resolveCookieToken(request); + log.info("쿠키에서 accessToken 추출 시작: {}", accessToken); if (accessToken != null) { + log.info("쿠키에서 accessToken 추출 끝: {}", accessToken); setAuthentication(accessToken); } filterChain.doFilter(request, response); } private void setAuthentication(final String accessToken) { - final Authentication authentication = accessTokenService.getAuthentication(accessToken); + final Authentication authentication = accessTokenProvider.getAuthentication(accessToken); SecurityContextHolder.getContext().setAuthentication(authentication); } - private String resolveToken(final HttpServletRequest request) { + private void validateSignUpToken(final String accessToken) { + log.info("parse Token {}", accessToken); + signUpTokenService.validateSignUpToken(accessToken); + } + + private String resolveCookieToken(final HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies == null) { return null; @@ -70,4 +93,14 @@ private String resolveToken(final HttpServletRequest request) { .map(Cookie::getValue) .orElse(null); } + + private String resolveToken(final HttpServletRequest request) { + final String header = request.getHeader(tokenHeader); + log.info("header {}", header); + + if (header != null && header.startsWith(BEARER_TYPE)) { + return header.replace(BEARER_TYPE, "").trim(); + } + return null; + } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/oauth/SignUpTokenService.java b/src/main/java/life/mosu/mosuserver/presentation/oauth/SignUpTokenService.java new file mode 100644 index 00000000..0252f289 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/oauth/SignUpTokenService.java @@ -0,0 +1,34 @@ +package life.mosu.mosuserver.presentation.oauth; + +import life.mosu.mosuserver.application.auth.provider.OneTimeTokenProvider; +import life.mosu.mosuserver.domain.auth.signup.SignUpToken; +import life.mosu.mosuserver.domain.auth.signup.SignUpTokenRepository; +import life.mosu.mosuserver.global.exception.AuthenticationException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SignUpTokenService { + private final OneTimeTokenProvider oneTimeTokenProvider; + private final SignUpTokenRepository repository; + + /** + * 회원가입 토큰을 검증하고, 유효하면 SignUpToken 객체를 반환합니다. + * @param token 검증할 토큰 + * @return 유효한 SignUpToken 객체 + * @throws AuthenticationException 토큰이 유효하지 않을 경우 + */ + public SignUpToken validateSignUpToken(final String token) { + SignUpToken signUpToken = oneTimeTokenProvider.getSignUpToken(token); + + // Redis에 토큰 정보가 없으면 유효하지 않은 것으로 간주 + if (signUpToken == null) { + throw new AuthenticationException("유효하지 않은 회원가입 토큰입니다."); + } + + repository.deleteByCertNum(signUpToken.certNum()); + + return signUpToken; + } +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/profile/ProfileController.java b/src/main/java/life/mosu/mosuserver/presentation/profile/ProfileController.java index 8a96c05a..73df1fd3 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/profile/ProfileController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/profile/ProfileController.java @@ -1,6 +1,7 @@ package life.mosu.mosuserver.presentation.profile; import jakarta.validation.Valid; +import life.mosu.mosuserver.application.auth.PrincipalDetails; import life.mosu.mosuserver.application.profile.ProfileService; import life.mosu.mosuserver.global.annotation.UserId; import life.mosu.mosuserver.global.util.ApiResponseWrapper; @@ -11,6 +12,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -29,28 +32,31 @@ public class ProfileController implements ProfileControllerDocs { @PostMapping public ResponseEntity> create( - @RequestParam Long userId, + @AuthenticationPrincipal final PrincipalDetails principalDetails, @Valid @RequestBody ProfileRequest request ) { + Long userId = principalDetails.getId(); + log.info("userId: {}", userId); profileService.registerProfile(userId, request); return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.CREATED, "프로필 등록 성공")); } @PutMapping -// @PreAuthorize("isAuthenticated()") public ResponseEntity> update( - @RequestParam Long userId, + @AuthenticationPrincipal final PrincipalDetails principalDetails, @Valid @RequestBody EditProfileRequest request ) { + Long userId = principalDetails.getId(); + log.info("userId: {}", userId); profileService.editProfile(userId, request); return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "프로필 수정 성공")); } @GetMapping public ResponseEntity> getProfile( - @UserId Long userId) { + @RequestParam Long userId + ) { ProfileDetailResponse response = profileService.getProfile(userId); return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "프로필 조회 성공", response)); } - -} +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/profile/ProfileControllerDocs.java b/src/main/java/life/mosu/mosuserver/presentation/profile/ProfileControllerDocs.java index 0072a7e9..c879424c 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/profile/ProfileControllerDocs.java +++ b/src/main/java/life/mosu/mosuserver/presentation/profile/ProfileControllerDocs.java @@ -9,11 +9,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import life.mosu.mosuserver.application.auth.PrincipalDetails; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.profile.dto.EditProfileRequest; import life.mosu.mosuserver.presentation.profile.dto.ProfileDetailResponse; import life.mosu.mosuserver.presentation.profile.dto.ProfileRequest; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -25,10 +27,7 @@ public interface ProfileControllerDocs { @ApiResponse(responseCode = "201", description = "프로필 등록 성공") }) ResponseEntity> create( - @Parameter(name = "userId", description = "사용자 ID", in = ParameterIn.QUERY) - @RequestParam Long userId, - - @Parameter(description = "프로필 등록 요청 정보", required = true) + @AuthenticationPrincipal final PrincipalDetails principalDetails, @Valid @RequestBody ProfileRequest request ); @@ -37,8 +36,7 @@ ResponseEntity> create( @ApiResponse(responseCode = "200", description = "프로필 수정 성공") }) ResponseEntity> update( - @Parameter(name = "userId", description = "회원 ID", in = ParameterIn.QUERY) - @RequestParam Long userId, + @AuthenticationPrincipal final PrincipalDetails principalDetails, @Parameter(description = "프로필 수정 요청 정보", required = true) @Valid @RequestBody EditProfileRequest request diff --git a/src/main/java/life/mosu/mosuserver/presentation/user/MyUserController.java b/src/main/java/life/mosu/mosuserver/presentation/user/MyUserController.java index 2a1db2e7..e0e4cf44 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/user/MyUserController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/user/MyUserController.java @@ -5,6 +5,8 @@ import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.user.dto.ChangePasswordRequest; import life.mosu.mosuserver.presentation.user.dto.ChangePasswordResponse; +import life.mosu.mosuserver.presentation.user.dto.FindIdRequest; +import life.mosu.mosuserver.presentation.user.dto.FindIdResponse; import life.mosu.mosuserver.presentation.user.dto.FindPasswordRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -16,23 +18,21 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/user") +@RequestMapping("/user/me") @RequiredArgsConstructor public class MyUserController implements MyUserControllerDocs { private final MyUserService myUserService; - @PostMapping("/me/password") - public ResponseEntity> changePassword( - @RequestParam Long userId, - @RequestBody @Valid ChangePasswordRequest request + @PostMapping("/find-id") + public ResponseEntity> findId( + @RequestBody @Valid FindIdRequest request ) { - ChangePasswordResponse response = myUserService.changePassword(userId, request); - return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "비밀번호 변경 성공", response)); + return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "아이디 찾기 요청 성공", null)); } - @PostMapping("/me/find-password") + @PostMapping("/find-password") public ResponseEntity> findPassword( @RequestParam Long userId, @RequestBody @Valid FindPasswordRequest request @@ -42,4 +42,13 @@ public ResponseEntity> findPassword( return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "비밀번호 찾기 요청 성공")); } + @PostMapping("/password") + public ResponseEntity> changePassword( + @RequestParam Long userId, + @RequestBody @Valid ChangePasswordRequest request + ) { + ChangePasswordResponse response = myUserService.changePassword(userId, request); + + return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "비밀번호 변경 성공", response)); + } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/user/dto/FindIdRequest.java b/src/main/java/life/mosu/mosuserver/presentation/user/dto/FindIdRequest.java new file mode 100644 index 00000000..2b655038 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/user/dto/FindIdRequest.java @@ -0,0 +1,8 @@ +package life.mosu.mosuserver.presentation.user.dto; + +public record FindIdRequest ( + String name, + String p +) { + +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/user/dto/FindIdResponse.java b/src/main/java/life/mosu/mosuserver/presentation/user/dto/FindIdResponse.java new file mode 100644 index 00000000..53006a1a --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/user/dto/FindIdResponse.java @@ -0,0 +1,6 @@ +package life.mosu.mosuserver.presentation.user.dto; + +public record FindIdResponse ( + String loginId +) { +} \ No newline at end of file diff --git a/src/main/resources/.DS_Store b/src/main/resources/.DS_Store new file mode 100644 index 00000000..99fbb9b4 Binary files /dev/null and b/src/main/resources/.DS_Store differ diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7fc30058..dc77aab6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -32,7 +32,7 @@ spring: open-in-view: false show-sql: true hibernate: - ddl-auto: create-drop + ddl-auto: update properties: hibernate: show_sql: true @@ -49,6 +49,10 @@ spring: messages: basename: messages encoding: UTF-8 + mvc: + view: + prefix: /WEB-INF/views/ + suffix: .jsp management: endpoints: diff --git a/src/main/resources/db/.DS_Store b/src/main/resources/db/.DS_Store new file mode 100644 index 00000000..d6405f7c Binary files /dev/null and b/src/main/resources/db/.DS_Store differ diff --git a/src/main/resources/security-config.yml b/src/main/resources/security-config.yml index d2f63f40..997304de 100644 --- a/src/main/resources/security-config.yml +++ b/src/main/resources/security-config.yml @@ -38,4 +38,8 @@ endpoints: reissue: /api/v1/auth/reissue target: - url: ${TARGET_URL} \ No newline at end of file + url: ${TARGET_URL} + +kmc: + cpid: ${KMC_CPID} + url-code: ${KMC_URLCODE} \ No newline at end of file diff --git a/src/main/webapp/.DS_Store b/src/main/webapp/.DS_Store new file mode 100644 index 00000000..5bd32db0 Binary files /dev/null and b/src/main/webapp/.DS_Store differ diff --git a/src/main/webapp/WEB-INF/.DS_Store b/src/main/webapp/WEB-INF/.DS_Store new file mode 100644 index 00000000..5fa9d514 Binary files /dev/null and b/src/main/webapp/WEB-INF/.DS_Store differ diff --git a/src/main/webapp/WEB-INF/views/request.jsp b/src/main/webapp/WEB-INF/views/request.jsp new file mode 100644 index 00000000..402bd494 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/request.jsp @@ -0,0 +1,18 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + + + KMC 본인인증 요청 + + + +

KMC 본인인증 페이지로 이동 중입니다. 잠시만 기다려주세요...

+ +[cite_start] +
<%-- [cite: 134] --%> + [cite_start] <%-- [cite: 137] --%> + [cite_start] <%-- [cite: 137] --%> + [cite_start] <%-- 서비스 요청 버전 V2 [cite: 284] --%> +
+ + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/result.jsp b/src/main/webapp/WEB-INF/views/result.jsp new file mode 100644 index 00000000..e2d139cd --- /dev/null +++ b/src/main/webapp/WEB-INF/views/result.jsp @@ -0,0 +1,58 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> + + + KMC 본인인증 결과 + + +

KMC 본인인증 결과

+ + +

인증 실패

+

오류: ${error}

+
+ +

인증 성공

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
이름${result.name}
생년월일${result.birth}
성별${result.gender == '0' ? '남자' : '여자'}
휴대폰 번호${result.phoneNo}
통신사${result.phoneCorp}
연계정보 (CI)${result.ci}
중복가입확인정보 (DI)${result.di}
요청번호${result.certNum}
+
+ +

결과 데이터가 없습니다.

+
+
+
+[다시 테스트하기] + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/start.jsp b/src/main/webapp/WEB-INF/views/start.jsp new file mode 100644 index 00000000..ca46a723 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/start.jsp @@ -0,0 +1,93 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + + + KMC 본인인증 시작 + + + + + + +

KMC 본인인증 서비스 테스트

+ +
+ + + + <%-- KMC 개발 가이드에 따라 tr_add 등의 추가 파라미터가 필요하면 여기에 추가합니다. [cite: 77] --%> + <%-- --%> + +
+ +
+
+ +

버튼 클릭 시 KMC 본인인증 서비스 팝업이 뜨거나 페이지가 전환됩니다.

+

반드시 "암호화된_데이터"와 "결과받을_URL"을 실제 값으로 채워 넣어야 합니다.

+ + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/step1.jsp b/src/main/webapp/WEB-INF/views/step1.jsp new file mode 100644 index 00000000..b7542152 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/step1.jsp @@ -0,0 +1,140 @@ +<% + //************************************************************************ + // // + // üҽ ׽Ʈ , // + // // + // 񽺿 ״ ϴ մϴ. // + // // + //************************************************************************ +%> +<% + response.setHeader("Pragma", "no-cache"); // HTTP1.0 ij + response.setDateHeader("Expires", 0); // proxy ij + response.setHeader("Pragma", "no-store"); // HTTP1.1 ij + if (request.getProtocol().equals("HTTP/1.1")) { + response.setHeader("Cache-Control", "no-cache"); // HTTP1.1 ij + } +%> +<%@ page contentType="text/html;charset=ksc5601" %> +<%@ page import="java.util.*,java.text.SimpleDateFormat" %> +<% + //¥ + Calendar today = Calendar.getInstance(); + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); + String day = sdf.format(today.getTime()); + + java.security.SecureRandom ran = new java.security.SecureRandom(); // SecureRandom Ŭ + ran.setSeed(System.currentTimeMillis()); // õ尪 + + // 100000 ̻ 999999 6ڸ + int randomNum = ran.nextInt(900000) + 100000; + + //reqNum ִ 40byte + String reqNum = day + randomNum; +%> + + + ׽Ʈ + + + + + +
+


+ ׽Ʈ
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID +
URLڵ +
ûȣ
ûϽ
+ +
߰DATA
URL
+

+ +
+
+
+ Sampleȭ ׽Ʈ ϰ ִ ȭԴϴ.
+
+
+ + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/views/step2.jsp b/src/main/webapp/WEB-INF/views/step2.jsp new file mode 100644 index 00000000..fce5dd62 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/step2.jsp @@ -0,0 +1,47 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + + + KMC 본인인증 요청 + + + + +

아래 버튼을 눌러 본인인증을 진행해주세요.

+ +
+ + + + + <%-- 버튼 클릭 시 submitKmcForm() 스크립트가 실행됩니다. --%> + +
+ + diff --git a/src/test/java/life/mosu/mosuserver/domain/user/service/UserEncoderServiceTest.java b/src/test/java/life/mosu/mosuserver/domain/user/service/UserEncoderServiceTest.java new file mode 100644 index 00000000..30db0a88 --- /dev/null +++ b/src/test/java/life/mosu/mosuserver/domain/user/service/UserEncoderServiceTest.java @@ -0,0 +1,19 @@ +//package life.mosu.mosuserver.domain.user.service; +// +//import static org.junit.jupiter.api.Assertions.*; +// +//import lombok.RequiredArgsConstructor; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +// +//@RequiredArgsConstructor(onConstructor_ = @Autowired) +//class UserEncoderServiceTest { +// +// +// private UserEncoderService userEncoderService; +// +// @Test +// void e() { +// userEncoderService.e("awef"); +// } +//} \ No newline at end of file diff --git a/src/test/java/life/mosu/mosuserver/infra/kmc/KmcServiceTest.java b/src/test/java/life/mosu/mosuserver/infra/kmc/KmcServiceTest.java new file mode 100644 index 00000000..3a50bde2 --- /dev/null +++ b/src/test/java/life/mosu/mosuserver/infra/kmc/KmcServiceTest.java @@ -0,0 +1,14 @@ +package life.mosu.mosuserver.infra.kmc; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class KmcServiceTest { + + @Test + @DisplayName("KMC 본인인증 요청 데이터 생성 테스트") + void encryptTrCert() { + Assertions.assertEquals(1, 1); + } +} \ No newline at end of file diff --git a/src/test/resources/security-config.yml b/src/test/resources/security-config.yml new file mode 100644 index 00000000..e63df47e --- /dev/null +++ b/src/test/resources/security-config.yml @@ -0,0 +1,46 @@ +spring: + security: + oauth2: + client: + registration: + kakao: + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: ${KAKAO_REDIRECT_URI} + scope: + - profile_nickname + client-name: kakao + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + +server: + servlet: + session: + cookie: + http-only: true + secure: true + same-site: None + +jwt: + secret: ${JWT_SECRET} + access-token: + expire-time: ${JWT_ACCESS_TOKEN_EXPIRE_TIME} + refresh-token: + expire-time: ${JWT_REFRESH_TOKEN_EXPIRE_TIME} + +endpoints: + reissue: /api/v1/auth/reissue + +target: + url: ${TARGET_URL} + +kmc: + cpid: ${KMC_CPID} + url-code: ${KMC_URLCODE} + expire-time: ${KMC_EXPIRE_TIME} \ No newline at end of file