Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 52 additions & 7 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import com.google.protobuf.gradle.id

plugins {
java
id("org.springframework.boot") version "3.5.9"
id("org.springframework.boot") version "4.0.2"
id("io.spring.dependency-management") version "1.1.7"
id("com.google.protobuf") version "0.9.5"
}

group = "flipnote"
Expand All @@ -14,33 +17,75 @@ java {
}
}

configurations {
compileOnly {
extendsFrom(configurations.annotationProcessor.get())
}
}

repositories {
mavenCentral()
}

extra["springGrpcVersion"] = "1.0.2"


dependencyManagement {
imports {
mavenBom("org.springframework.grpc:spring-grpc-dependencies:1.0.2")
}
}
Comment on lines +27 to +31
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

dependencyManagement 블록이 중복 선언되어 있습니다. 첫 번째 블록(lines 27–31)을 제거하세요.

Lines 27–31과 63–67이 동일한 Spring gRPC BOM을 두 번 임포트하고 있습니다. 첫 번째 블록은 버전을 "1.0.2"로 하드코딩하고 있으며, 24번 줄에 선언된 springGrpcVersion extra property를 사용하지 않아 일관성도 깨집니다. 두 번째 블록(lines 63–67)이 property를 올바르게 참조하므로, 첫 번째 블록을 제거해야 합니다.

🔧 수정 제안
-dependencyManagement {
-    imports {
-        mavenBom("org.springframework.grpc:spring-grpc-dependencies:1.0.2")
-    }
-}
-
-
 dependencies {

Also applies to: 63-67

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build.gradle.kts` around lines 27 - 31, Remove the duplicate
dependencyManagement block that hardcodes the Spring gRPC BOM (the block
containing mavenBom("org.springframework.grpc:spring-grpc-dependencies:1.0.2")),
leaving only the other dependencyManagement block that references the extra
property springGrpcVersion defined earlier; in short, delete the first
dependencyManagement block so the build uses the springGrpcVersion property
consistently.


dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.security:spring-security-crypto")
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")

// gRPC
implementation("io.grpc:grpc-services")
implementation("org.springframework.grpc:spring-grpc-spring-boot-starter")

// Email
implementation("com.resend:resend-java:3.1.0")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")

implementation("org.springframework.boot:spring-boot-starter-aspectj")

compileOnly("org.projectlombok:lombok")
runtimeOnly("com.mysql:mysql-connector-j")
annotationProcessor("org.projectlombok:lombok")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.grpc:spring-grpc-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testRuntimeOnly("com.h2database:h2")
}

dependencyManagement {
imports {
mavenBom("org.springframework.grpc:spring-grpc-dependencies:${property("springGrpcVersion")}")
}
}

protobuf {
protoc {
artifact = "com.google.protobuf:protoc"
}
plugins {
id("grpc") {
artifact = "io.grpc:protoc-gen-grpc-java"
}
}
generateProtoTasks {
all().forEach {
it.plugins {
id("grpc") {
option("@generated=omit")
}
}
}
}
}

tasks.withType<Test> {
useJUnitPlatform()
}
6 changes: 6 additions & 0 deletions src/main/java/flipnote/user/UserApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.resilience.annotation.EnableResilientMethods;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@ConfigurationPropertiesScan
@EnableAsync
@EnableResilientMethods
public class UserApplication {

public static void main(String[] args) {
Expand Down
232 changes: 232 additions & 0 deletions src/main/java/flipnote/user/auth/application/AuthService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package flipnote.user.auth.application;

import flipnote.user.auth.domain.AuthErrorCode;
import flipnote.user.auth.domain.TokenClaims;
import flipnote.user.auth.domain.TokenPair;
import flipnote.user.auth.domain.event.EmailVerificationSendEvent;
import flipnote.user.auth.domain.event.PasswordResetCreateEvent;
import flipnote.user.auth.infrastructure.jwt.JwtProvider;
import flipnote.user.auth.infrastructure.redis.EmailVerificationRepository;
import flipnote.user.auth.infrastructure.redis.PasswordResetRepository;
import flipnote.user.auth.infrastructure.redis.PasswordResetTokenGenerator;
import flipnote.user.auth.infrastructure.redis.SessionInvalidationRepository;
import flipnote.user.auth.infrastructure.redis.TokenBlacklistRepository;
import flipnote.user.auth.infrastructure.redis.VerificationCodeGenerator;
import flipnote.user.auth.presentation.dto.request.ChangePasswordRequest;
import flipnote.user.auth.presentation.dto.request.LoginRequest;
import flipnote.user.auth.presentation.dto.request.SignupRequest;
import flipnote.user.auth.presentation.dto.response.SocialLinksResponse;
import flipnote.user.auth.presentation.dto.response.TokenValidateResponse;
import flipnote.user.auth.presentation.dto.response.UserResponse;
import flipnote.user.global.config.ClientProperties;
import flipnote.user.global.exception.UserException;
import flipnote.user.user.domain.OAuthLink;
import flipnote.user.user.domain.OAuthLinkRepository;
import flipnote.user.user.domain.User;
import flipnote.user.user.domain.UserErrorCode;
import flipnote.user.user.domain.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthService {

private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
private final TokenBlacklistRepository tokenBlacklistRepository;
private final EmailVerificationRepository emailVerificationRepository;
private final PasswordResetRepository passwordResetRepository;
private final OAuthLinkRepository oAuthLinkRepository;
private final SessionInvalidationRepository sessionInvalidationRepository;
private final VerificationCodeGenerator verificationCodeGenerator;
private final PasswordResetTokenGenerator passwordResetTokenGenerator;
private final ClientProperties clientProperties;
private final ApplicationEventPublisher eventPublisher;

@Transactional
public UserResponse register(SignupRequest request) {
if (!emailVerificationRepository.isVerified(request.getEmail())) {
throw new UserException(AuthErrorCode.UNVERIFIED_EMAIL);
}

if (userRepository.existsByEmail(request.getEmail())) {
throw new UserException(AuthErrorCode.EMAIL_ALREADY_EXISTS);
}

User user = User.builder()
.email(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.name(request.getName())
.nickname(request.getNickname())
.phone(request.getPhone())
.smsAgree(Boolean.TRUE.equals(request.getSmsAgree()))
.build();

User savedUser = userRepository.save(user);
return UserResponse.from(savedUser);
}

public TokenPair login(LoginRequest request) {
User user = userRepository.findByEmailAndStatus(request.getEmail(), User.Status.ACTIVE)
.orElseThrow(() -> new UserException(AuthErrorCode.INVALID_CREDENTIALS));

if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new UserException(AuthErrorCode.INVALID_CREDENTIALS);
}

return jwtProvider.generateTokenPair(user);
}

public void logout(String refreshToken) {
if (refreshToken != null && jwtProvider.isTokenValid(refreshToken)) {
long remaining = jwtProvider.getRemainingExpiration(refreshToken);
if (remaining > 0) {
tokenBlacklistRepository.add(refreshToken, remaining);
}
}
}

public TokenPair refreshToken(String refreshToken) {
if (refreshToken == null || !jwtProvider.isTokenValid(refreshToken)) {
throw new UserException(AuthErrorCode.INVALID_TOKEN);
}

if (tokenBlacklistRepository.isBlacklisted(refreshToken)) {
throw new UserException(AuthErrorCode.BLACKLISTED_TOKEN);
}

TokenClaims claims = jwtProvider.extractClaims(refreshToken);

sessionInvalidationRepository.getInvalidatedAtMillis(claims.userId()).ifPresent(invalidatedAtMillis -> {
if (jwtProvider.getIssuedAt(refreshToken).getTime() < invalidatedAtMillis) {
throw new UserException(AuthErrorCode.INVALIDATED_SESSION);
}
});

User user = findActiveUser(claims.userId());

long remaining = jwtProvider.getRemainingExpiration(refreshToken);
if (remaining > 0) {
tokenBlacklistRepository.add(refreshToken, remaining);
}

return jwtProvider.generateTokenPair(user);
}

@Transactional
public void changePassword(Long userId, ChangePasswordRequest request) {
User user = findActiveUser(userId);

if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPassword())) {
throw new UserException(AuthErrorCode.PASSWORD_MISMATCH);
}

user.changePassword(passwordEncoder.encode(request.getNewPassword()));
sessionInvalidationRepository.invalidate(user.getId(), jwtProvider.getRefreshTokenExpiration());
}

public TokenValidateResponse validateToken(String token) {
if (!jwtProvider.isTokenValid(token)) {
throw new UserException(AuthErrorCode.INVALID_TOKEN);
}

if (tokenBlacklistRepository.isBlacklisted(token)) {
throw new UserException(AuthErrorCode.BLACKLISTED_TOKEN);
}

TokenClaims claims = jwtProvider.extractClaims(token);

sessionInvalidationRepository.getInvalidatedAtMillis(claims.userId()).ifPresent(invalidatedAtMillis -> {
if (jwtProvider.getIssuedAt(token).getTime() < invalidatedAtMillis) {
throw new UserException(AuthErrorCode.INVALIDATED_SESSION);
}
});

findActiveUser(claims.userId());

return new TokenValidateResponse(claims.userId(), claims.email(), claims.role());
}

public void sendEmailVerificationCode(String email) {
if (emailVerificationRepository.hasCode(email)) {
throw new UserException(AuthErrorCode.ALREADY_ISSUED_VERIFICATION_CODE);
}

String code = verificationCodeGenerator.generate();
emailVerificationRepository.saveCode(email, code);
eventPublisher.publishEvent(new EmailVerificationSendEvent(email, code));
}

public void verifyEmail(String email, String code) {
if (!emailVerificationRepository.hasCode(email)) {
throw new UserException(AuthErrorCode.NOT_ISSUED_VERIFICATION_CODE);
}

String savedCode = emailVerificationRepository.getCode(email);
if (!code.equals(savedCode)) {
throw new UserException(AuthErrorCode.INVALID_VERIFICATION_CODE);
}

emailVerificationRepository.deleteCode(email);
emailVerificationRepository.markVerified(email);
}

public void requestPasswordReset(String email) {
// 사용자가 없어도 정상 반환 (이메일 존재 여부 노출 방지)
if (!userRepository.existsByEmail(email)) {
return;
}

if (passwordResetRepository.hasToken(email)) {
throw new UserException(AuthErrorCode.ALREADY_SENT_PASSWORD_RESET_LINK);
}

String token = passwordResetTokenGenerator.generate();
passwordResetRepository.save(token, email);

String link = clientProperties.getUrl() + clientProperties.getPaths().getPasswordReset()
+ "?token=" + token;
eventPublisher.publishEvent(new PasswordResetCreateEvent(email, link));
}

@Transactional
public void resetPassword(String token, String newPassword) {
String email = passwordResetRepository.findEmailByToken(token);
if (email == null) {
throw new UserException(AuthErrorCode.INVALID_PASSWORD_RESET_TOKEN);
}

User user = userRepository.findByEmailAndStatus(email, User.Status.ACTIVE)
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));

user.changePassword(passwordEncoder.encode(newPassword));
sessionInvalidationRepository.invalidate(user.getId(), jwtProvider.getRefreshTokenExpiration());
passwordResetRepository.delete(token, email);
}

public SocialLinksResponse getSocialLinks(Long userId) {
List<OAuthLink> links = oAuthLinkRepository.findByUser_Id(userId);
return SocialLinksResponse.from(links);
}

@Transactional
public void deleteSocialLink(Long userId, Long socialLinkId) {
if (!oAuthLinkRepository.existsByIdAndUser_Id(socialLinkId, userId)) {
throw new UserException(AuthErrorCode.NOT_REGISTERED_SOCIAL_ACCOUNT);
}
oAuthLinkRepository.deleteById(socialLinkId);
}

private User findActiveUser(Long userId) {
return userRepository.findByIdAndStatus(userId, User.Status.ACTIVE)
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
}
}
Loading