diff --git a/build.gradle.kts b/build.gradle.kts index d12a94d..82f366a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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" @@ -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") + } +} + 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 { useJUnitPlatform() } diff --git a/src/main/java/flipnote/user/UserApplication.java b/src/main/java/flipnote/user/UserApplication.java index c752932..a922aa7 100644 --- a/src/main/java/flipnote/user/UserApplication.java +++ b/src/main/java/flipnote/user/UserApplication.java @@ -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) { diff --git a/src/main/java/flipnote/user/auth/application/AuthService.java b/src/main/java/flipnote/user/auth/application/AuthService.java new file mode 100644 index 0000000..12a5181 --- /dev/null +++ b/src/main/java/flipnote/user/auth/application/AuthService.java @@ -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 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)); + } +} diff --git a/src/main/java/flipnote/user/auth/application/OAuthService.java b/src/main/java/flipnote/user/auth/application/OAuthService.java new file mode 100644 index 0000000..3a6660b --- /dev/null +++ b/src/main/java/flipnote/user/auth/application/OAuthService.java @@ -0,0 +1,129 @@ +package flipnote.user.auth.application; + +import flipnote.user.auth.domain.AuthErrorCode; +import flipnote.user.auth.domain.TokenPair; +import flipnote.user.auth.infrastructure.jwt.JwtProvider; +import flipnote.user.auth.infrastructure.oauth.OAuthApiClient; +import flipnote.user.auth.infrastructure.oauth.OAuth2UserInfo; +import flipnote.user.auth.infrastructure.oauth.PkceUtil; +import flipnote.user.auth.infrastructure.redis.SocialLinkTokenRepository; +import flipnote.user.global.config.OAuthProperties; +import flipnote.user.global.constants.HttpConstants; +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 jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class OAuthService { + + private final PkceUtil pkceUtil; + private final OAuthApiClient oAuthApiClient; + private final OAuthLinkRepository oAuthLinkRepository; + private final UserRepository userRepository; + private final SocialLinkTokenRepository socialLinkTokenRepository; + private final JwtProvider jwtProvider; + private final OAuthProperties oAuthProperties; + + public record AuthorizationRedirect(String authorizeUri, ResponseCookie verifierCookie) {} + + private static final int VERIFIER_COOKIE_MAX_AGE = 180; + + public AuthorizationRedirect getAuthorizationUri(String providerName, HttpServletRequest request, + Long userId) { + OAuthProperties.Provider provider = resolveProvider(providerName); + + String codeVerifier = pkceUtil.generateCodeVerifier(); + String codeChallenge = pkceUtil.generateCodeChallenge(codeVerifier); + + String state = null; + if (userId != null) { + state = UUID.randomUUID().toString(); + socialLinkTokenRepository.save(userId, state); + } + + String authorizeUri = oAuthApiClient.buildAuthorizeUri(request, provider, codeChallenge, state); + + ResponseCookie verifierCookie = ResponseCookie.from(HttpConstants.OAUTH_VERIFIER_COOKIE, codeVerifier) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(VERIFIER_COOKIE_MAX_AGE) + .sameSite("Lax") + .build(); + + return new AuthorizationRedirect(authorizeUri, verifierCookie); + } + + public TokenPair socialLogin(String providerName, String code, String codeVerifier, + HttpServletRequest request) { + OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request); + + OAuthLink oAuthLink = oAuthLinkRepository + .findByProviderAndProviderIdWithUser(userInfo.getProvider(), userInfo.getProviderId()) + .orElseThrow(() -> new UserException(AuthErrorCode.NOT_REGISTERED_SOCIAL_ACCOUNT)); + + return jwtProvider.generateTokenPair(oAuthLink.getUser()); + } + + @Transactional + public void linkSocialAccount(String providerName, String code, String state, + String codeVerifier, HttpServletRequest request) { + Long userId = socialLinkTokenRepository.findUserIdByState(state) + .orElseThrow(() -> new UserException(AuthErrorCode.INVALID_SOCIAL_LINK_TOKEN)); + + socialLinkTokenRepository.delete(state); + + OAuth2UserInfo userInfo = getOAuth2UserInfo(providerName, code, codeVerifier, request); + + if (oAuthLinkRepository.existsByUser_IdAndProviderAndProviderId( + userId, userInfo.getProvider(), userInfo.getProviderId())) { + throw new UserException(AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT); + } + + User user = userRepository.findByIdAndStatus(userId, User.Status.ACTIVE) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + + OAuthLink link = OAuthLink.builder() + .provider(userInfo.getProvider()) + .providerId(userInfo.getProviderId()) + .user(user) + .build(); + oAuthLinkRepository.save(link); + } + + private OAuth2UserInfo getOAuth2UserInfo(String providerName, String code, + String codeVerifier, HttpServletRequest request) { + OAuthProperties.Provider provider = resolveProvider(providerName); + String accessToken = oAuthApiClient.requestAccessToken(provider, code, codeVerifier, request); + Map attributes = oAuthApiClient.requestUserInfo(provider, accessToken); + return oAuthApiClient.createUserInfo(providerName, attributes); + } + + private OAuthProperties.Provider resolveProvider(String providerName) { + Map providers = oAuthProperties.getProviders(); + if (providers == null) { + throw new UserException(AuthErrorCode.INVALID_OAUTH_PROVIDER); + } + OAuthProperties.Provider provider = providers.get(providerName.toLowerCase()); + if (provider == null) { + log.warn("지원하지 않는 OAuth Provider: {}", providerName); + throw new UserException(AuthErrorCode.INVALID_OAUTH_PROVIDER); + } + return provider; + } +} diff --git a/src/main/java/flipnote/user/auth/domain/AuthErrorCode.java b/src/main/java/flipnote/user/auth/domain/AuthErrorCode.java new file mode 100644 index 0000000..6ff29e5 --- /dev/null +++ b/src/main/java/flipnote/user/auth/domain/AuthErrorCode.java @@ -0,0 +1,37 @@ +package flipnote.user.auth.domain; + +import flipnote.user.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_001", "이메일 또는 비밀번호가 올바르지 않습니다."), + ALREADY_ISSUED_VERIFICATION_CODE(HttpStatus.TOO_MANY_REQUESTS, "AUTH_002", "이미 인증코드가 발송되었습니다. 잠시 후 다시 시도해 주세요."), + NOT_ISSUED_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "AUTH_003", "인증코드가 발송되지 않았습니다."), + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "AUTH_004", "인증코드가 올바르지 않습니다."), + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "AUTH_005", "이미 사용 중인 이메일입니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_006", "유효하지 않은 토큰입니다."), + UNVERIFIED_EMAIL(HttpStatus.FORBIDDEN, "AUTH_007", "이메일 인증이 완료되지 않았습니다."), + ALREADY_SENT_PASSWORD_RESET_LINK(HttpStatus.TOO_MANY_REQUESTS, "AUTH_008", "이미 비밀번호 재설정 링크가 발송되었습니다. 잠시 후 다시 시도해 주세요."), + INVALID_PASSWORD_RESET_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_009", "유효하지 않은 비밀번호 재설정 토큰입니다."), + INVALID_SOCIAL_LINK_TOKEN(HttpStatus.BAD_REQUEST, "AUTH_010", "유효하지 않은 소셜 연동 토큰입니다."), + ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "AUTH_011", "이미 연결된 소셜 계정입니다."), + NOT_REGISTERED_SOCIAL_ACCOUNT(HttpStatus.NOT_FOUND, "AUTH_012", "연결된 소셜 계정이 없습니다."), + INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_013", "지원하지 않는 OAuth 제공자입니다."), + BLACKLISTED_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_015", "무효화된 토큰입니다."), + INVALIDATED_SESSION(HttpStatus.UNAUTHORIZED, "AUTH_016", "세션이 무효화되었습니다. 다시 로그인해 주세요."), + PASSWORD_MISMATCH(HttpStatus.BAD_REQUEST, "AUTH_017", "현재 비밀번호가 일치하지 않습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public int getStatus() { + return httpStatus.value(); + } +} diff --git a/src/main/java/flipnote/user/auth/domain/PasswordResetConstants.java b/src/main/java/flipnote/user/auth/domain/PasswordResetConstants.java new file mode 100644 index 0000000..94878de --- /dev/null +++ b/src/main/java/flipnote/user/auth/domain/PasswordResetConstants.java @@ -0,0 +1,10 @@ +package flipnote.user.auth.domain; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class PasswordResetConstants { + + public static final int TOKEN_TTL_MINUTES = 30; +} diff --git a/src/main/java/flipnote/user/auth/domain/TokenClaims.java b/src/main/java/flipnote/user/auth/domain/TokenClaims.java new file mode 100644 index 0000000..52bc265 --- /dev/null +++ b/src/main/java/flipnote/user/auth/domain/TokenClaims.java @@ -0,0 +1,8 @@ +package flipnote.user.auth.domain; + +public record TokenClaims( + Long userId, + String email, + String role +) { +} diff --git a/src/main/java/flipnote/user/auth/domain/TokenPair.java b/src/main/java/flipnote/user/auth/domain/TokenPair.java new file mode 100644 index 0000000..81cb9d4 --- /dev/null +++ b/src/main/java/flipnote/user/auth/domain/TokenPair.java @@ -0,0 +1,4 @@ +package flipnote.user.auth.domain; + +public record TokenPair(String accessToken, String refreshToken) { +} diff --git a/src/main/java/flipnote/user/auth/domain/VerificationConstants.java b/src/main/java/flipnote/user/auth/domain/VerificationConstants.java new file mode 100644 index 0000000..d92bc97 --- /dev/null +++ b/src/main/java/flipnote/user/auth/domain/VerificationConstants.java @@ -0,0 +1,10 @@ +package flipnote.user.auth.domain; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class VerificationConstants { + + public static final int CODE_TTL_MINUTES = 5; +} diff --git a/src/main/java/flipnote/user/auth/domain/event/EmailVerificationSendEvent.java b/src/main/java/flipnote/user/auth/domain/event/EmailVerificationSendEvent.java new file mode 100644 index 0000000..0373988 --- /dev/null +++ b/src/main/java/flipnote/user/auth/domain/event/EmailVerificationSendEvent.java @@ -0,0 +1,7 @@ +package flipnote.user.auth.domain.event; + +public record EmailVerificationSendEvent( + String to, + String code +) { +} diff --git a/src/main/java/flipnote/user/auth/domain/event/PasswordResetCreateEvent.java b/src/main/java/flipnote/user/auth/domain/event/PasswordResetCreateEvent.java new file mode 100644 index 0000000..b1c6daf --- /dev/null +++ b/src/main/java/flipnote/user/auth/domain/event/PasswordResetCreateEvent.java @@ -0,0 +1,7 @@ +package flipnote.user.auth.domain.event; + +public record PasswordResetCreateEvent( + String to, + String link +) { +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/jwt/JwtProvider.java b/src/main/java/flipnote/user/auth/infrastructure/jwt/JwtProvider.java new file mode 100644 index 0000000..9cd7a6e --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/jwt/JwtProvider.java @@ -0,0 +1,108 @@ +package flipnote.user.auth.infrastructure.jwt; + +import flipnote.user.auth.domain.TokenClaims; +import flipnote.user.auth.domain.TokenPair; +import flipnote.user.user.domain.User; +import flipnote.user.global.config.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.UUID; + +@Component +public class JwtProvider { + + private final SecretKey secretKey; + private final long accessTokenExpiration; + private final long refreshTokenExpiration; + + public JwtProvider(JwtProperties jwtProperties) { + this.secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)); + this.accessTokenExpiration = jwtProperties.getAccessTokenExpiration(); + this.refreshTokenExpiration = jwtProperties.getRefreshTokenExpiration(); + } + + public TokenPair generateTokenPair(User user) { + String accessToken = generateToken(user, accessTokenExpiration); + String refreshToken = generateToken(user, refreshTokenExpiration); + return new TokenPair(accessToken, refreshToken); + } + + private String generateToken(User user, long expiration) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + expiration); + + return Jwts.builder() + .id(UUID.randomUUID().toString()) + .subject(user.getEmail()) + .claim("userId", user.getId()) + .claim("role", user.getRole().name()) + .issuedAt(now) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + } + + public Claims parseClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public TokenClaims extractClaims(String token) { + Claims claims = parseClaims(token); + return new TokenClaims( + claims.get("userId", Long.class), + claims.getSubject(), + claims.get("role", String.class) + ); + } + + public long getRemainingExpiration(String token) { + Claims claims = parseClaims(token); + Date expiration = claims.getExpiration(); + return expiration.getTime() - System.currentTimeMillis(); + } + + public Date getIssuedAt(String token) { + Claims claims = parseClaims(token); + return claims.getIssuedAt(); + } + + public boolean isTokenValid(String token) { + try { + parseClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + public boolean isTokenExpired(String token) { + try { + parseClaims(token); + return false; + } catch (ExpiredJwtException e) { + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + public long getAccessTokenExpiration() { + return accessTokenExpiration; + } + + public long getRefreshTokenExpiration() { + return refreshTokenExpiration; + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/listener/EmailVerificationEventListener.java b/src/main/java/flipnote/user/auth/infrastructure/listener/EmailVerificationEventListener.java new file mode 100644 index 0000000..d16fdd6 --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/listener/EmailVerificationEventListener.java @@ -0,0 +1,27 @@ +package flipnote.user.auth.infrastructure.listener; + +import org.springframework.context.event.EventListener; +import org.springframework.resilience.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import flipnote.user.auth.domain.VerificationConstants; +import flipnote.user.auth.domain.event.EmailVerificationSendEvent; +import flipnote.user.auth.infrastructure.mail.MailService; +import flipnote.user.global.exception.EmailSendException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EmailVerificationEventListener { + private final MailService mailService; + + @Async + @EventListener + @Retryable(delay = 2000, multiplier = 2.0, maxRetries = 3, includes = EmailSendException.class) + public void handle(EmailVerificationSendEvent event) { + mailService.sendVerificationCode(event.to(), event.code(), VerificationConstants.CODE_TTL_MINUTES); + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/listener/PasswordResetEventListener.java b/src/main/java/flipnote/user/auth/infrastructure/listener/PasswordResetEventListener.java new file mode 100644 index 0000000..2518e79 --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/listener/PasswordResetEventListener.java @@ -0,0 +1,27 @@ +package flipnote.user.auth.infrastructure.listener; + +import org.springframework.context.event.EventListener; +import org.springframework.resilience.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import flipnote.user.auth.domain.PasswordResetConstants; +import flipnote.user.auth.domain.event.PasswordResetCreateEvent; +import flipnote.user.auth.infrastructure.mail.MailService; +import flipnote.user.global.exception.EmailSendException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PasswordResetEventListener { + private final MailService mailService; + + @Async + @EventListener + @Retryable(delay = 2000, multiplier = 2.0, maxRetries = 3, includes = EmailSendException.class) + public void handle(PasswordResetCreateEvent event) { + mailService.sendPasswordResetLink(event.to(), event.link(), PasswordResetConstants.TOKEN_TTL_MINUTES); + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/mail/MailService.java b/src/main/java/flipnote/user/auth/infrastructure/mail/MailService.java new file mode 100644 index 0000000..da8c47c --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/mail/MailService.java @@ -0,0 +1,8 @@ +package flipnote.user.auth.infrastructure.mail; + +public interface MailService { + + void sendVerificationCode(String to, String code, int ttl); + + void sendPasswordResetLink(String to, String link, int ttl); +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/mail/ResendMailService.java b/src/main/java/flipnote/user/auth/infrastructure/mail/ResendMailService.java new file mode 100644 index 0000000..4e57af1 --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/mail/ResendMailService.java @@ -0,0 +1,68 @@ +package flipnote.user.auth.infrastructure.mail; + +import com.resend.Resend; +import com.resend.core.exception.ResendException; +import com.resend.services.emails.model.CreateEmailOptions; +import flipnote.user.global.config.ResendProperties; +import flipnote.user.global.exception.EmailSendException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ResendMailService implements MailService { + + private final ResendProperties resendProperties; + private final Resend resend; + private final SpringTemplateEngine templateEngine; + + @Override + public void sendVerificationCode(String to, String code, int ttl) { + Context context = new Context(); + context.setVariable("code", code); + context.setVariable("validMinutes", ttl); + + String html = templateEngine.process("email/email-verification", context); + + CreateEmailOptions params = CreateEmailOptions.builder() + .from(resendProperties.getFromEmail()) + .to(to) + .subject("이메일 인증번호 안내") + .html(html) + .build(); + + try { + resend.emails().send(params); + } catch (ResendException e) { + log.error("이메일 인증번호 발송 실패: to={}, ttl={}분", to, ttl, e); + throw new EmailSendException(e); + } + } + + @Override + public void sendPasswordResetLink(String to, String link, int ttl) { + Context context = new Context(); + context.setVariable("link", link); + context.setVariable("validMinutes", ttl); + + String html = templateEngine.process("email/password-reset", context); + + CreateEmailOptions params = CreateEmailOptions.builder() + .from(resendProperties.getFromEmail()) + .to(to) + .subject("비밀번호 재설정 안내") + .html(html) + .build(); + + try { + resend.emails().send(params); + } catch (ResendException e) { + log.error("비밀번호 재설정 링크 발송 실패: to={}, ttl={}분", to, ttl, e); + throw new EmailSendException(e); + } + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/oauth/GoogleUserInfo.java b/src/main/java/flipnote/user/auth/infrastructure/oauth/GoogleUserInfo.java new file mode 100644 index 0000000..b9e5b01 --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/oauth/GoogleUserInfo.java @@ -0,0 +1,32 @@ +package flipnote.user.auth.infrastructure.oauth; + +import java.util.Map; + +public class GoogleUserInfo implements OAuth2UserInfo { + + private final Map attributes; + + public GoogleUserInfo(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProviderId() { + return String.valueOf(attributes.get("sub")); + } + + @Override + public String getProvider() { + return "google"; + } + + @Override + public String getEmail() { + return String.valueOf(attributes.get("email")); + } + + @Override + public String getName() { + return String.valueOf(attributes.get("name")); + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/oauth/OAuth2UserInfo.java b/src/main/java/flipnote/user/auth/infrastructure/oauth/OAuth2UserInfo.java new file mode 100644 index 0000000..5a394a5 --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/oauth/OAuth2UserInfo.java @@ -0,0 +1,12 @@ +package flipnote.user.auth.infrastructure.oauth; + +public interface OAuth2UserInfo { + + String getProviderId(); + + String getProvider(); + + String getEmail(); + + String getName(); +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/oauth/OAuthApiClient.java b/src/main/java/flipnote/user/auth/infrastructure/oauth/OAuthApiClient.java new file mode 100644 index 0000000..c53c970 --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/oauth/OAuthApiClient.java @@ -0,0 +1,96 @@ +package flipnote.user.auth.infrastructure.oauth; + +import java.util.Map; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; +import org.springframework.web.util.UriComponentsBuilder; + +import flipnote.user.global.config.OAuthProperties; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +@Service +@RequiredArgsConstructor +public class OAuthApiClient { + + private final RestClient restClient; + private final ObjectMapper objectMapper; + + public String requestAccessToken(OAuthProperties.Provider provider, String code, + String codeVerifier, HttpServletRequest request) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", provider.getClientId()); + params.add("client_secret", provider.getClientSecret()); + params.add("redirect_uri", buildRedirectUri(request, provider.getRedirectUri())); + params.add("code", code); + params.add("code_verifier", codeVerifier); + + try { + String responseBody = restClient.post() + .uri(provider.getTokenUri()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(params) + .retrieve() + .body(String.class); + + Map responseMap = objectMapper.readValue(responseBody, new TypeReference<>() {}); + return (String) responseMap.get("access_token"); + } catch (Exception e) { + throw new RuntimeException("Failed to get OAuth access token", e); + } + } + + public Map requestUserInfo(OAuthProperties.Provider provider, String accessToken) { + try { + String responseBody = restClient.get() + .uri(provider.getUserInfoUri()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .body(String.class); + + return objectMapper.readValue(responseBody, new TypeReference<>() {}); + } catch (Exception e) { + throw new RuntimeException("Failed to get OAuth user info", e); + } + } + + public OAuth2UserInfo createUserInfo(String providerName, Map attributes) { + return switch (providerName.toLowerCase()) { + case "google" -> new GoogleUserInfo(attributes); + default -> throw new IllegalArgumentException("Unsupported OAuth provider: " + providerName); + }; + } + + public String buildAuthorizeUri(HttpServletRequest request, OAuthProperties.Provider provider, + String codeChallenge, String state) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(provider.getAuthorizationUri()) + .queryParam("client_id", provider.getClientId()) + .queryParam("redirect_uri", buildRedirectUri(request, provider.getRedirectUri())) + .queryParam("response_type", "code") + .queryParam("scope", String.join(" ", provider.getScope())) + .queryParam("code_challenge", codeChallenge) + .queryParam("code_challenge_method", "S256"); + + if (state != null) { + builder.queryParam("state", state); + } + + return builder.toUriString(); + } + + private String buildRedirectUri(HttpServletRequest request, String path) { + return ServletUriComponentsBuilder.fromRequestUri(request) + .replacePath(path) + .build() + .toUriString(); + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/oauth/PkceUtil.java b/src/main/java/flipnote/user/auth/infrastructure/oauth/PkceUtil.java new file mode 100644 index 0000000..544266c --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/oauth/PkceUtil.java @@ -0,0 +1,33 @@ +package flipnote.user.auth.infrastructure.oauth; + +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +@Component +public class PkceUtil { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + public String generateCodeVerifier() { + byte[] codeVerifier = new byte[32]; + SECURE_RANDOM.nextBytes(codeVerifier); + return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier); + } + + public String generateCodeChallenge(String codeVerifier) { + try { + byte[] bytes = codeVerifier.getBytes(StandardCharsets.US_ASCII); + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(bytes); + byte[] digest = md.digest(); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not supported", e); + } + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/redis/EmailVerificationRepository.java b/src/main/java/flipnote/user/auth/infrastructure/redis/EmailVerificationRepository.java new file mode 100644 index 0000000..6218947 --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/EmailVerificationRepository.java @@ -0,0 +1,57 @@ +package flipnote.user.auth.infrastructure.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class EmailVerificationRepository { + + private static final String CODE_KEY_PREFIX = "email:verification:code:"; + private static final String VERIFIED_KEY_PREFIX = "email:verification:verified:"; + private static final long CODE_TTL_MINUTES = 5; + private static final long VERIFIED_TTL_MINUTES = 10; + + private final StringRedisTemplate redisTemplate; + + public void saveCode(String email, String code) { + redisTemplate.opsForValue().set( + CODE_KEY_PREFIX + email, + code, + CODE_TTL_MINUTES, + TimeUnit.MINUTES + ); + } + + public String getCode(String email) { + return redisTemplate.opsForValue().get(CODE_KEY_PREFIX + email); + } + + public boolean hasCode(String email) { + return Boolean.TRUE.equals(redisTemplate.hasKey(CODE_KEY_PREFIX + email)); + } + + public void deleteCode(String email) { + redisTemplate.delete(CODE_KEY_PREFIX + email); + } + + public void markVerified(String email) { + redisTemplate.opsForValue().set( + VERIFIED_KEY_PREFIX + email, + "verified", + VERIFIED_TTL_MINUTES, + TimeUnit.MINUTES + ); + } + + public boolean isVerified(String email) { + return Boolean.TRUE.equals(redisTemplate.hasKey(VERIFIED_KEY_PREFIX + email)); + } + + public void deleteVerified(String email) { + redisTemplate.delete(VERIFIED_KEY_PREFIX + email); + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetRepository.java b/src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetRepository.java new file mode 100644 index 0000000..c821d5d --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetRepository.java @@ -0,0 +1,46 @@ +package flipnote.user.auth.infrastructure.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class PasswordResetRepository { + + private static final String TOKEN_KEY_PREFIX = "password:reset:token:"; + private static final String EMAIL_KEY_PREFIX = "password:reset:email:"; + private static final long TTL_MINUTES = 30; + + private final StringRedisTemplate redisTemplate; + + public boolean hasToken(String email) { + return Boolean.TRUE.equals(redisTemplate.hasKey(EMAIL_KEY_PREFIX + email)); + } + + public void save(String token, String email) { + redisTemplate.opsForValue().set( + TOKEN_KEY_PREFIX + token, + email, + TTL_MINUTES, + TimeUnit.MINUTES + ); + redisTemplate.opsForValue().set( + EMAIL_KEY_PREFIX + email, + token, + TTL_MINUTES, + TimeUnit.MINUTES + ); + } + + public String findEmailByToken(String token) { + return redisTemplate.opsForValue().get(TOKEN_KEY_PREFIX + token); + } + + public void delete(String token, String email) { + redisTemplate.delete(TOKEN_KEY_PREFIX + token); + redisTemplate.delete(EMAIL_KEY_PREFIX + email); + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetTokenGenerator.java b/src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetTokenGenerator.java new file mode 100644 index 0000000..1e8797f --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/PasswordResetTokenGenerator.java @@ -0,0 +1,13 @@ +package flipnote.user.auth.infrastructure.redis; + +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class PasswordResetTokenGenerator { + + public String generate() { + return UUID.randomUUID().toString(); + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/redis/SessionInvalidationRepository.java b/src/main/java/flipnote/user/auth/infrastructure/redis/SessionInvalidationRepository.java new file mode 100644 index 0000000..e494496 --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/SessionInvalidationRepository.java @@ -0,0 +1,35 @@ +package flipnote.user.auth.infrastructure.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class SessionInvalidationRepository { + + private static final String KEY_PREFIX = "session:invalidated:"; + + private final StringRedisTemplate redisTemplate; + + public void invalidate(Long userId, long ttlMillis) { + if (ttlMillis <= 0) { + return; + } + + redisTemplate.opsForValue().set( + KEY_PREFIX + userId, + String.valueOf(System.currentTimeMillis()), + ttlMillis, + TimeUnit.MILLISECONDS + ); + } + + public Optional getInvalidatedAtMillis(Long userId) { + String value = redisTemplate.opsForValue().get(KEY_PREFIX + userId); + return Optional.ofNullable(value).map(Long::parseLong); + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/redis/SocialLinkTokenRepository.java b/src/main/java/flipnote/user/auth/infrastructure/redis/SocialLinkTokenRepository.java new file mode 100644 index 0000000..1c4222b --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/SocialLinkTokenRepository.java @@ -0,0 +1,31 @@ +package flipnote.user.auth.infrastructure.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class SocialLinkTokenRepository { + + private static final String KEY_PREFIX = "oauth:link:state:"; + private static final long TTL_MINUTES = 3; + + private final StringRedisTemplate redisTemplate; + + public void save(Long userId, String state) { + redisTemplate.opsForValue().set(KEY_PREFIX + state, userId.toString(), TTL_MINUTES, TimeUnit.MINUTES); + } + + public Optional findUserIdByState(String state) { + String value = redisTemplate.opsForValue().get(KEY_PREFIX + state); + return Optional.ofNullable(value).map(Long::parseLong); + } + + public void delete(String state) { + redisTemplate.delete(KEY_PREFIX + state); + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/redis/TokenBlacklistRepository.java b/src/main/java/flipnote/user/auth/infrastructure/redis/TokenBlacklistRepository.java new file mode 100644 index 0000000..9a1d899 --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/TokenBlacklistRepository.java @@ -0,0 +1,29 @@ +package flipnote.user.auth.infrastructure.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class TokenBlacklistRepository { + + private static final String KEY_PREFIX = "token:blacklist:"; + + private final StringRedisTemplate redisTemplate; + + public void add(String token, long expirationMillis) { + redisTemplate.opsForValue().set( + KEY_PREFIX + token, + "blacklisted", + expirationMillis, + TimeUnit.MILLISECONDS + ); + } + + public boolean isBlacklisted(String token) { + return Boolean.TRUE.equals(redisTemplate.hasKey(KEY_PREFIX + token)); + } +} diff --git a/src/main/java/flipnote/user/auth/infrastructure/redis/VerificationCodeGenerator.java b/src/main/java/flipnote/user/auth/infrastructure/redis/VerificationCodeGenerator.java new file mode 100644 index 0000000..301156d --- /dev/null +++ b/src/main/java/flipnote/user/auth/infrastructure/redis/VerificationCodeGenerator.java @@ -0,0 +1,16 @@ +package flipnote.user.auth.infrastructure.redis; + +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; + +@Component +public class VerificationCodeGenerator { + + private static final SecureRandom RANDOM = new SecureRandom(); + + public String generate() { + int code = RANDOM.nextInt(1_000_000); + return String.format("%06d", code); + } +} diff --git a/src/main/java/flipnote/user/auth/presentation/AuthController.java b/src/main/java/flipnote/user/auth/presentation/AuthController.java new file mode 100644 index 0000000..060f8d6 --- /dev/null +++ b/src/main/java/flipnote/user/auth/presentation/AuthController.java @@ -0,0 +1,137 @@ +package flipnote.user.auth.presentation; + +import flipnote.user.auth.application.AuthService; +import flipnote.user.auth.infrastructure.jwt.JwtProvider; +import flipnote.user.auth.domain.TokenPair; +import flipnote.user.auth.presentation.dto.request.ChangePasswordRequest; +import flipnote.user.auth.presentation.dto.request.EmailVerificationRequest; +import flipnote.user.auth.presentation.dto.request.EmailVerifyRequest; +import flipnote.user.auth.presentation.dto.request.LoginRequest; +import flipnote.user.auth.presentation.dto.request.PasswordResetCreateRequest; +import flipnote.user.auth.presentation.dto.request.PasswordResetRequest; +import flipnote.user.auth.presentation.dto.request.SignupRequest; +import flipnote.user.auth.presentation.dto.request.TokenValidateRequest; +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.constants.HttpConstants; +import flipnote.user.global.util.CookieUtil; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/v1/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final JwtProvider jwtProvider; + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody SignupRequest request) { + UserResponse response = authService.register(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request, + HttpServletResponse response) { + TokenPair tokenPair = authService.login(request); + setTokenCookies(response, tokenPair); + return ResponseEntity.ok().build(); + } + + @PostMapping("/logout") + public ResponseEntity logout( + @CookieValue(name = HttpConstants.REFRESH_TOKEN_COOKIE, required = false) String refreshToken, + HttpServletResponse response) { + authService.logout(refreshToken); + CookieUtil.deleteCookie(response, HttpConstants.ACCESS_TOKEN_COOKIE); + CookieUtil.deleteCookie(response, HttpConstants.REFRESH_TOKEN_COOKIE); + return ResponseEntity.ok().build(); + } + + @PostMapping("/token/refresh") + public ResponseEntity refreshToken( + @CookieValue(name = HttpConstants.REFRESH_TOKEN_COOKIE) String refreshToken, + HttpServletResponse response) { + TokenPair tokenPair = authService.refreshToken(refreshToken); + setTokenCookies(response, tokenPair); + return ResponseEntity.ok().build(); + } + + @PostMapping("/token/validate") + public ResponseEntity validateToken( + @Valid @RequestBody TokenValidateRequest request) { + TokenValidateResponse result = authService.validateToken(request.getToken()); + return ResponseEntity.ok(result); + } + + @PatchMapping("/password") + public ResponseEntity changePassword( + @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId, + @Valid @RequestBody ChangePasswordRequest request, + HttpServletResponse response) { + authService.changePassword(userId, request); + CookieUtil.deleteCookie(response, HttpConstants.ACCESS_TOKEN_COOKIE); + CookieUtil.deleteCookie(response, HttpConstants.REFRESH_TOKEN_COOKIE); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/email-verification/request") + public ResponseEntity sendEmailVerification( + @Valid @RequestBody EmailVerificationRequest request) { + authService.sendEmailVerificationCode(request.getEmail()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/email-verification") + public ResponseEntity verifyEmail( + @Valid @RequestBody EmailVerifyRequest request) { + authService.verifyEmail(request.getEmail(), request.getCode()); + return ResponseEntity.ok().build(); + } + + @PostMapping("/password-reset/request") + public ResponseEntity requestPasswordReset( + @Valid @RequestBody PasswordResetCreateRequest request) { + authService.requestPasswordReset(request.getEmail()); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/password-reset") + public ResponseEntity resetPassword( + @Valid @RequestBody PasswordResetRequest request, + HttpServletResponse response) { + authService.resetPassword(request.getToken(), request.getPassword()); + CookieUtil.deleteCookie(response, HttpConstants.ACCESS_TOKEN_COOKIE); + CookieUtil.deleteCookie(response, HttpConstants.REFRESH_TOKEN_COOKIE); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/social-links") + public ResponseEntity getSocialLinks( + @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId) { + SocialLinksResponse response = authService.getSocialLinks(userId); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/social-links/{socialLinkId}") + public ResponseEntity deleteSocialLink( + @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId, + @PathVariable Long socialLinkId) { + authService.deleteSocialLink(userId, socialLinkId); + return ResponseEntity.noContent().build(); + } + + private void setTokenCookies(HttpServletResponse response, TokenPair tokenPair) { + CookieUtil.addCookie(response, HttpConstants.ACCESS_TOKEN_COOKIE, tokenPair.accessToken(), + jwtProvider.getAccessTokenExpiration() / 1000); + CookieUtil.addCookie(response, HttpConstants.REFRESH_TOKEN_COOKIE, tokenPair.refreshToken(), + jwtProvider.getRefreshTokenExpiration() / 1000); + } +} diff --git a/src/main/java/flipnote/user/auth/presentation/OAuthController.java b/src/main/java/flipnote/user/auth/presentation/OAuthController.java new file mode 100644 index 0000000..6f787ed --- /dev/null +++ b/src/main/java/flipnote/user/auth/presentation/OAuthController.java @@ -0,0 +1,101 @@ +package flipnote.user.auth.presentation; + +import flipnote.user.auth.application.OAuthService; +import flipnote.user.auth.domain.AuthErrorCode; +import flipnote.user.global.exception.UserException; +import flipnote.user.auth.domain.TokenPair; +import flipnote.user.global.config.ClientProperties; +import flipnote.user.global.constants.HttpConstants; +import flipnote.user.global.util.CookieUtil; +import flipnote.user.auth.infrastructure.jwt.JwtProvider; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class OAuthController { + + private final OAuthService oAuthService; + private final JwtProvider jwtProvider; + private final ClientProperties clientProperties; + + @GetMapping("/oauth2/authorization/{provider}") + public ResponseEntity redirectToProvider( + @PathVariable String provider, + @RequestHeader(value = HttpConstants.USER_ID_HEADER, required = false) Long userId, + HttpServletRequest request) { + OAuthService.AuthorizationRedirect redirect = oAuthService.getAuthorizationUri(provider, request, userId); + + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.SET_COOKIE, redirect.verifierCookie().toString()) + .location(URI.create(redirect.authorizeUri())) + .build(); + } + + @GetMapping("/oauth2/callback/{provider}") + public ResponseEntity handleCallback( + @PathVariable String provider, + @RequestParam String code, + @RequestParam(required = false) String state, + @CookieValue(HttpConstants.OAUTH_VERIFIER_COOKIE) String codeVerifier, + HttpServletRequest request, + HttpServletResponse response) { + + CookieUtil.deleteCookie(response, HttpConstants.OAUTH_VERIFIER_COOKIE); + + boolean isSocialLinkRequest = StringUtils.hasText(state); + if (isSocialLinkRequest) { + return handleSocialLink(provider, code, state, codeVerifier, request); + } + return handleSocialLogin(provider, code, codeVerifier, request, response); + } + + private ResponseEntity handleSocialLogin(String provider, String code, String codeVerifier, + HttpServletRequest request, HttpServletResponse response) { + try { + TokenPair tokenPair = oAuthService.socialLogin(provider, code, codeVerifier, request); + CookieUtil.addCookie(response, HttpConstants.ACCESS_TOKEN_COOKIE, tokenPair.accessToken(), + jwtProvider.getAccessTokenExpiration() / 1000); + CookieUtil.addCookie(response, HttpConstants.REFRESH_TOKEN_COOKIE, tokenPair.refreshToken(), + jwtProvider.getRefreshTokenExpiration() / 1000); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLoginSuccess())) + .build(); + } catch (Exception e) { + log.warn("소셜 로그인 처리 실패. provider: {}", provider, e); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLoginFailure())) + .build(); + } + } + + private ResponseEntity handleSocialLink(String provider, String code, String state, + String codeVerifier, HttpServletRequest request) { + try { + oAuthService.linkSocialAccount(provider, code, state, codeVerifier, request); + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkSuccess())) + .build(); + } catch (UserException e) { + log.warn("소셜 계정 연동 처리 실패. provider: {}", provider, e); + if (e.getErrorCode() == AuthErrorCode.ALREADY_LINKED_SOCIAL_ACCOUNT) { + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkConflict())) + .build(); + } + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(clientProperties.getUrl() + clientProperties.getPaths().getSocialLinkFailure())) + .build(); + } + } +} diff --git a/src/main/java/flipnote/user/auth/presentation/dto/request/ChangePasswordRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/ChangePasswordRequest.java new file mode 100644 index 0000000..68fca89 --- /dev/null +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/ChangePasswordRequest.java @@ -0,0 +1,18 @@ +package flipnote.user.auth.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ChangePasswordRequest { + + @NotBlank(message = "현재 비밀번호는 필수입니다") + private String currentPassword; + + @NotBlank(message = "새 비밀번호는 필수입니다") + @Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하여야 합니다") + private String newPassword; +} diff --git a/src/main/java/flipnote/user/auth/presentation/dto/request/EmailVerificationRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/EmailVerificationRequest.java new file mode 100644 index 0000000..9914235 --- /dev/null +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/EmailVerificationRequest.java @@ -0,0 +1,15 @@ +package flipnote.user.auth.presentation.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class EmailVerificationRequest { + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + private String email; +} diff --git a/src/main/java/flipnote/user/auth/presentation/dto/request/EmailVerifyRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/EmailVerifyRequest.java new file mode 100644 index 0000000..add0490 --- /dev/null +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/EmailVerifyRequest.java @@ -0,0 +1,20 @@ +package flipnote.user.auth.presentation.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class EmailVerifyRequest { + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + private String email; + + @NotBlank(message = "인증코드는 필수입니다") + @Size(min = 6, max = 6, message = "인증코드는 6자리여야 합니다") + private String code; +} diff --git a/src/main/java/flipnote/user/dto/LoginRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/LoginRequest.java similarity index 89% rename from src/main/java/flipnote/user/dto/LoginRequest.java rename to src/main/java/flipnote/user/auth/presentation/dto/request/LoginRequest.java index efe0fa1..e84a68e 100644 --- a/src/main/java/flipnote/user/dto/LoginRequest.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/LoginRequest.java @@ -1,4 +1,4 @@ -package flipnote.user.dto; +package flipnote.user.auth.presentation.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetCreateRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetCreateRequest.java new file mode 100644 index 0000000..805ec9e --- /dev/null +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetCreateRequest.java @@ -0,0 +1,15 @@ +package flipnote.user.auth.presentation.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PasswordResetCreateRequest { + + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "올바른 이메일 형식이 아닙니다") + private String email; +} diff --git a/src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetRequest.java new file mode 100644 index 0000000..5bf5a9f --- /dev/null +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/PasswordResetRequest.java @@ -0,0 +1,18 @@ +package flipnote.user.auth.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PasswordResetRequest { + + @NotBlank(message = "토큰은 필수입니다") + private String token; + + @NotBlank(message = "새 비밀번호는 필수입니다") + @Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하여야 합니다") + private String password; +} diff --git a/src/main/java/flipnote/user/dto/SignupRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/SignupRequest.java similarity index 63% rename from src/main/java/flipnote/user/dto/SignupRequest.java rename to src/main/java/flipnote/user/auth/presentation/dto/request/SignupRequest.java index 339e084..a59af8e 100644 --- a/src/main/java/flipnote/user/dto/SignupRequest.java +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/SignupRequest.java @@ -1,7 +1,9 @@ -package flipnote.user.dto; +package flipnote.user.auth.presentation.dto.request; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.NoArgsConstructor; @@ -18,7 +20,16 @@ public class SignupRequest { @Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하여야 합니다") private String password; + @NotBlank(message = "이름은 필수입니다") + private String name; + @NotBlank(message = "닉네임은 필수입니다") @Size(min = 2, max = 50, message = "닉네임은 2자 이상 50자 이하여야 합니다") private String nickname; + + @NotNull(message = "SMS 수신 동의 여부는 필수입니다") + private Boolean smsAgree; + + @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 전화번호 형식이 아닙니다") + private String phone; } diff --git a/src/main/java/flipnote/user/auth/presentation/dto/request/TokenValidateRequest.java b/src/main/java/flipnote/user/auth/presentation/dto/request/TokenValidateRequest.java new file mode 100644 index 0000000..45055ce --- /dev/null +++ b/src/main/java/flipnote/user/auth/presentation/dto/request/TokenValidateRequest.java @@ -0,0 +1,13 @@ +package flipnote.user.auth.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TokenValidateRequest { + + @NotBlank(message = "토큰은 필수입니다") + private String token; +} diff --git a/src/main/java/flipnote/user/auth/presentation/dto/response/SocialLinkResponse.java b/src/main/java/flipnote/user/auth/presentation/dto/response/SocialLinkResponse.java new file mode 100644 index 0000000..1c7f250 --- /dev/null +++ b/src/main/java/flipnote/user/auth/presentation/dto/response/SocialLinkResponse.java @@ -0,0 +1,23 @@ +package flipnote.user.auth.presentation.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import flipnote.user.user.domain.OAuthLink; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class SocialLinkResponse { + + private Long socialLinkId; + private String provider; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime linkedAt; + + public static SocialLinkResponse from(OAuthLink link) { + return new SocialLinkResponse(link.getId(), link.getProvider(), link.getLinkedAt()); + } +} diff --git a/src/main/java/flipnote/user/auth/presentation/dto/response/SocialLinksResponse.java b/src/main/java/flipnote/user/auth/presentation/dto/response/SocialLinksResponse.java new file mode 100644 index 0000000..d5f3bbd --- /dev/null +++ b/src/main/java/flipnote/user/auth/presentation/dto/response/SocialLinksResponse.java @@ -0,0 +1,21 @@ +package flipnote.user.auth.presentation.dto.response; + +import flipnote.user.user.domain.OAuthLink; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class SocialLinksResponse { + + private List socialLinks; + + public static SocialLinksResponse from(List links) { + List socialLinks = links.stream() + .map(SocialLinkResponse::from) + .toList(); + return new SocialLinksResponse(socialLinks); + } +} diff --git a/src/main/java/flipnote/user/auth/presentation/dto/response/TokenValidateResponse.java b/src/main/java/flipnote/user/auth/presentation/dto/response/TokenValidateResponse.java new file mode 100644 index 0000000..6b799b5 --- /dev/null +++ b/src/main/java/flipnote/user/auth/presentation/dto/response/TokenValidateResponse.java @@ -0,0 +1,13 @@ +package flipnote.user.auth.presentation.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class TokenValidateResponse { + + private Long userId; + private String email; + private String role; +} diff --git a/src/main/java/flipnote/user/auth/presentation/dto/response/UserResponse.java b/src/main/java/flipnote/user/auth/presentation/dto/response/UserResponse.java new file mode 100644 index 0000000..66a9541 --- /dev/null +++ b/src/main/java/flipnote/user/auth/presentation/dto/response/UserResponse.java @@ -0,0 +1,16 @@ +package flipnote.user.auth.presentation.dto.response; + +import flipnote.user.user.domain.User; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserResponse { + + private Long userId; + + public static UserResponse from(User user) { + return new UserResponse(user.getId()); + } +} diff --git a/src/main/java/flipnote/user/config/SecurityConfig.java b/src/main/java/flipnote/user/config/SecurityConfig.java deleted file mode 100644 index 50c9bc9..0000000 --- a/src/main/java/flipnote/user/config/SecurityConfig.java +++ /dev/null @@ -1,15 +0,0 @@ -package flipnote.user.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; - -@Configuration -public class SecurityConfig { - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } -} diff --git a/src/main/java/flipnote/user/controller/UserController.java b/src/main/java/flipnote/user/controller/UserController.java deleted file mode 100644 index 49d75ac..0000000 --- a/src/main/java/flipnote/user/controller/UserController.java +++ /dev/null @@ -1,44 +0,0 @@ -package flipnote.user.controller; - -import flipnote.user.dto.LoginRequest; -import flipnote.user.dto.LoginResponse; -import flipnote.user.dto.SignupRequest; -import flipnote.user.dto.UserResponse; -import flipnote.user.service.UserService; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/v1/users") -@RequiredArgsConstructor -public class UserController { - - private final UserService userService; - - @PostMapping("/signup") - public ResponseEntity signup(@Valid @RequestBody SignupRequest request) { - UserResponse response = userService.signup(request); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } - - @PostMapping("/login") - public ResponseEntity login(@Valid @RequestBody LoginRequest request) { - LoginResponse response = userService.login(request); - return ResponseEntity.ok(response); - } - - @GetMapping("/me") - public ResponseEntity getMyProfile(@RequestHeader("X-User-Id") Long userId) { - UserResponse response = userService.getProfile(userId); - return ResponseEntity.ok(response); - } - - @GetMapping("/{id}") - public ResponseEntity getProfile(@PathVariable Long id) { - UserResponse response = userService.getProfile(id); - return ResponseEntity.ok(response); - } -} diff --git a/src/main/java/flipnote/user/dto/LoginResponse.java b/src/main/java/flipnote/user/dto/LoginResponse.java deleted file mode 100644 index ac68320..0000000 --- a/src/main/java/flipnote/user/dto/LoginResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package flipnote.user.dto; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class LoginResponse { - - private String accessToken; - private String tokenType; - private Long expiresIn; - - public static LoginResponse of(String accessToken, Long expiresIn) { - return new LoginResponse(accessToken, "Bearer", expiresIn); - } -} diff --git a/src/main/java/flipnote/user/dto/UserResponse.java b/src/main/java/flipnote/user/dto/UserResponse.java deleted file mode 100644 index 30ba839..0000000 --- a/src/main/java/flipnote/user/dto/UserResponse.java +++ /dev/null @@ -1,28 +0,0 @@ -package flipnote.user.dto; - -import flipnote.user.entity.User; -import lombok.AllArgsConstructor; -import lombok.Getter; - -import java.time.LocalDateTime; - -@Getter -@AllArgsConstructor -public class UserResponse { - - private Long id; - private String email; - private String nickname; - private String role; - private LocalDateTime createdAt; - - public static UserResponse from(User user) { - return new UserResponse( - user.getId(), - user.getEmail(), - user.getNickname(), - user.getRole().name(), - user.getCreatedAt() - ); - } -} diff --git a/src/main/java/flipnote/user/entity/User.java b/src/main/java/flipnote/user/entity/User.java deleted file mode 100644 index deafeb0..0000000 --- a/src/main/java/flipnote/user/entity/User.java +++ /dev/null @@ -1,56 +0,0 @@ -package flipnote.user.entity; - -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "users") -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class User { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true, length = 100) - private String email; - - @Column(nullable = false) - private String password; - - @Column(nullable = false, length = 50) - private String nickname; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - private Role role; - - @Column(nullable = false, updatable = false) - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; - - @Builder - public User(String email, String password, String nickname, Role role) { - this.email = email; - this.password = password; - this.nickname = nickname; - this.role = role != null ? role : Role.USER; - this.createdAt = LocalDateTime.now(); - } - - @PreUpdate - protected void onUpdate() { - this.updatedAt = LocalDateTime.now(); - } - - public enum Role { - USER, ADMIN - } -} diff --git a/src/main/java/flipnote/user/exception/GlobalExceptionHandler.java b/src/main/java/flipnote/user/exception/GlobalExceptionHandler.java deleted file mode 100644 index 3a75e8b..0000000 --- a/src/main/java/flipnote/user/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,32 +0,0 @@ -package flipnote.user.exception; - -import org.springframework.http.ResponseEntity; -import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -import java.util.HashMap; -import java.util.Map; - -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(UserException.class) - public ResponseEntity> handleUserException(UserException e) { - Map error = new HashMap<>(); - error.put("message", e.getMessage()); - return ResponseEntity.status(e.getStatus()).body(error); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { - Map errors = new HashMap<>(); - e.getBindingResult().getAllErrors().forEach(error -> { - String fieldName = ((FieldError) error).getField(); - String message = error.getDefaultMessage(); - errors.put(fieldName, message); - }); - return ResponseEntity.badRequest().body(errors); - } -} diff --git a/src/main/java/flipnote/user/exception/UserException.java b/src/main/java/flipnote/user/exception/UserException.java deleted file mode 100644 index e9da6a8..0000000 --- a/src/main/java/flipnote/user/exception/UserException.java +++ /dev/null @@ -1,27 +0,0 @@ -package flipnote.user.exception; - -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public class UserException extends RuntimeException { - - private final HttpStatus status; - - public UserException(String message, HttpStatus status) { - super(message); - this.status = status; - } - - public static UserException emailAlreadyExists() { - return new UserException("이미 사용 중인 이메일입니다", HttpStatus.CONFLICT); - } - - public static UserException invalidCredentials() { - return new UserException("이메일 또는 비밀번호가 올바르지 않습니다", HttpStatus.UNAUTHORIZED); - } - - public static UserException userNotFound() { - return new UserException("사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND); - } -} diff --git a/src/main/java/flipnote/user/global/config/AppConfig.java b/src/main/java/flipnote/user/global/config/AppConfig.java new file mode 100644 index 0000000..c8b5cfe --- /dev/null +++ b/src/main/java/flipnote/user/global/config/AppConfig.java @@ -0,0 +1,35 @@ +package flipnote.user.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.client.RestClient; + +import java.util.concurrent.Executor; + +@Configuration +public class AppConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public RestClient restClient() { + return RestClient.create(); + } + + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(16); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("async-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/flipnote/user/global/config/ClientProperties.java b/src/main/java/flipnote/user/global/config/ClientProperties.java new file mode 100644 index 0000000..5334472 --- /dev/null +++ b/src/main/java/flipnote/user/global/config/ClientProperties.java @@ -0,0 +1,25 @@ +package flipnote.user.global.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "app.client") +public class ClientProperties { + + private final String url; + private final Paths paths; + + @Getter + @RequiredArgsConstructor + public static class Paths { + private final String passwordReset; + private final String socialLinkSuccess; + private final String socialLinkFailure; + private final String socialLinkConflict; + private final String socialLoginSuccess; + private final String socialLoginFailure; + } +} diff --git a/src/main/java/flipnote/user/global/config/JpaAuditingConfig.java b/src/main/java/flipnote/user/global/config/JpaAuditingConfig.java new file mode 100644 index 0000000..6e3060c --- /dev/null +++ b/src/main/java/flipnote/user/global/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package flipnote.user.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/src/main/java/flipnote/user/global/config/JwtProperties.java b/src/main/java/flipnote/user/global/config/JwtProperties.java new file mode 100644 index 0000000..6feaa72 --- /dev/null +++ b/src/main/java/flipnote/user/global/config/JwtProperties.java @@ -0,0 +1,15 @@ +package flipnote.user.global.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "jwt") +public class JwtProperties { + + private final String secret; + private final long accessTokenExpiration; + private final long refreshTokenExpiration; +} diff --git a/src/main/java/flipnote/user/global/config/OAuthProperties.java b/src/main/java/flipnote/user/global/config/OAuthProperties.java new file mode 100644 index 0000000..5a1a788 --- /dev/null +++ b/src/main/java/flipnote/user/global/config/OAuthProperties.java @@ -0,0 +1,29 @@ +package flipnote.user.global.config; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; +import java.util.Map; + +@Getter +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "app.oauth2") +public class OAuthProperties { + + private final Map providers; + + @Getter + @Setter + public static class Provider { + private String clientId; + private String clientSecret; + private String redirectUri; + private String authorizationUri; + private String tokenUri; + private String userInfoUri; + private List scope; + } +} diff --git a/src/main/java/flipnote/user/global/config/ResendConfig.java b/src/main/java/flipnote/user/global/config/ResendConfig.java new file mode 100644 index 0000000..ed0b74e --- /dev/null +++ b/src/main/java/flipnote/user/global/config/ResendConfig.java @@ -0,0 +1,18 @@ +package flipnote.user.global.config; + +import com.resend.Resend; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class ResendConfig { + + private final ResendProperties resendProperties; + + @Bean + public Resend resend() { + return new Resend(resendProperties.getApiKey()); + } +} diff --git a/src/main/java/flipnote/user/global/config/ResendProperties.java b/src/main/java/flipnote/user/global/config/ResendProperties.java new file mode 100644 index 0000000..b1f28c6 --- /dev/null +++ b/src/main/java/flipnote/user/global/config/ResendProperties.java @@ -0,0 +1,20 @@ +package flipnote.user.global.config; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Getter +@Validated +@RequiredArgsConstructor +@ConfigurationProperties(prefix = "app.resend") +public class ResendProperties { + + @NotEmpty + private final String apiKey; + + @NotEmpty + private final String fromEmail; +} diff --git a/src/main/java/flipnote/user/global/constants/HttpConstants.java b/src/main/java/flipnote/user/global/constants/HttpConstants.java new file mode 100644 index 0000000..2f6b519 --- /dev/null +++ b/src/main/java/flipnote/user/global/constants/HttpConstants.java @@ -0,0 +1,12 @@ +package flipnote.user.global.constants; + +public final class HttpConstants { + + private HttpConstants() { + } + + public static final String USER_ID_HEADER = "X-USER-ID"; + public static final String ACCESS_TOKEN_COOKIE = "accessToken"; + public static final String REFRESH_TOKEN_COOKIE = "refreshToken"; + public static final String OAUTH_VERIFIER_COOKIE = "oauth2_auth_request"; +} diff --git a/src/main/java/flipnote/user/global/entity/BaseEntity.java b/src/main/java/flipnote/user/global/entity/BaseEntity.java new file mode 100644 index 0000000..61b9181 --- /dev/null +++ b/src/main/java/flipnote/user/global/entity/BaseEntity.java @@ -0,0 +1,24 @@ +package flipnote.user.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime modifiedAt; +} diff --git a/src/main/java/flipnote/user/global/error/ApiResponse.java b/src/main/java/flipnote/user/global/error/ApiResponse.java new file mode 100644 index 0000000..00b659d --- /dev/null +++ b/src/main/java/flipnote/user/global/error/ApiResponse.java @@ -0,0 +1,60 @@ +package flipnote.user.global.error; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.validation.BindingResult; + +import java.util.List; + +@Getter +@Builder +public class ApiResponse { + + private final int status; + private final String code; + private final String message; + private final T data; + + public static ApiResponse error(ErrorCode errorCode) { + return ApiResponse.builder() + .status(errorCode.getStatus()) + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + } + + public static ApiResponse> validationError(BindingResult bindingResult) { + return ApiResponse.>builder() + .status(400) + .code("INVALID_INPUT") + .message("입력값이 올바르지 않습니다.") + .data(FieldError.of(bindingResult)) + .build(); + } + + public static ApiResponse internalError() { + return ApiResponse.builder() + .status(500) + .code("INTERNAL_SERVER_ERROR") + .message("서버 오류가 발생했습니다.") + .build(); + } + + @Getter + @AllArgsConstructor + public static class FieldError { + private String field; + private String rejectedValue; + private String reason; + + public static List of(BindingResult bindingResult) { + return bindingResult.getFieldErrors().stream() + .map(e -> new FieldError( + e.getField(), + e.getRejectedValue() == null ? "" : String.valueOf(e.getRejectedValue()), + e.getDefaultMessage())) + .toList(); + } + } +} diff --git a/src/main/java/flipnote/user/global/error/ErrorCode.java b/src/main/java/flipnote/user/global/error/ErrorCode.java new file mode 100644 index 0000000..d043924 --- /dev/null +++ b/src/main/java/flipnote/user/global/error/ErrorCode.java @@ -0,0 +1,10 @@ +package flipnote.user.global.error; + +public interface ErrorCode { + + int getStatus(); + + String getCode(); + + String getMessage(); +} diff --git a/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java b/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..7150a0b --- /dev/null +++ b/src/main/java/flipnote/user/global/error/GlobalExceptionHandler.java @@ -0,0 +1,44 @@ +package flipnote.user.global.error; + +import flipnote.user.global.exception.UserException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestCookieException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.List; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(UserException.class) + public ResponseEntity> handleUserException(UserException e) { + log.warn("UserException: {}", e.getMessage()); + return ResponseEntity.status(e.getErrorCode().getStatus()).body(ApiResponse.error(e.getErrorCode())); + } + + @ExceptionHandler(MissingRequestCookieException.class) + public ResponseEntity> handleMissingCookie(MissingRequestCookieException e) { + ApiResponse response = ApiResponse.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .code("MISSING_COOKIE") + .message("필수 쿠키가 누락되었습니다.") + .build(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleValidationException(MethodArgumentNotValidException e) { + return ResponseEntity.badRequest().body(ApiResponse.validationError(e.getBindingResult())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + log.error("Unhandled exception", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.internalError()); + } +} diff --git a/src/main/java/flipnote/user/global/exception/EmailSendException.java b/src/main/java/flipnote/user/global/exception/EmailSendException.java new file mode 100644 index 0000000..e8a11b5 --- /dev/null +++ b/src/main/java/flipnote/user/global/exception/EmailSendException.java @@ -0,0 +1,8 @@ +package flipnote.user.global.exception; + +public class EmailSendException extends RuntimeException { + + public EmailSendException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/flipnote/user/global/exception/UserException.java b/src/main/java/flipnote/user/global/exception/UserException.java new file mode 100644 index 0000000..b5a1bd8 --- /dev/null +++ b/src/main/java/flipnote/user/global/exception/UserException.java @@ -0,0 +1,15 @@ +package flipnote.user.global.exception; + +import flipnote.user.global.error.ErrorCode; +import lombok.Getter; + +@Getter +public class UserException extends RuntimeException { + + private final ErrorCode errorCode; + + public UserException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/flipnote/user/global/util/CookieUtil.java b/src/main/java/flipnote/user/global/util/CookieUtil.java new file mode 100644 index 0000000..bfeda9b --- /dev/null +++ b/src/main/java/flipnote/user/global/util/CookieUtil.java @@ -0,0 +1,32 @@ +package flipnote.user.global.util; + +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseCookie; + +public final class CookieUtil { + + private CookieUtil() { + } + + public static void addCookie(HttpServletResponse response, String name, String value, long maxAgeSeconds) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(maxAgeSeconds) + .sameSite("Lax") + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + } + + public static void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("Lax") + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + } +} diff --git a/src/main/java/flipnote/user/repository/UserRepository.java b/src/main/java/flipnote/user/repository/UserRepository.java deleted file mode 100644 index 9f9e892..0000000 --- a/src/main/java/flipnote/user/repository/UserRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package flipnote.user.repository; - -import flipnote.user.entity.User; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface UserRepository extends JpaRepository { - - Optional findByEmail(String email); - - boolean existsByEmail(String email); -} diff --git a/src/main/java/flipnote/user/security/JwtProvider.java b/src/main/java/flipnote/user/security/JwtProvider.java deleted file mode 100644 index 37b4119..0000000 --- a/src/main/java/flipnote/user/security/JwtProvider.java +++ /dev/null @@ -1,44 +0,0 @@ -package flipnote.user.security; - -import flipnote.user.entity.User; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import lombok.Getter; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.util.Date; - -@Component -public class JwtProvider { - - private final SecretKey secretKey; - - @Getter - private final long expiration; - - public JwtProvider( - @Value("${jwt.secret}") String secret, - @Value("${jwt.expiration}") long expiration) { - this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); - this.expiration = expiration; - } - - public String generateToken(User user) { - Date now = new Date(); - Date expiryDate = new Date(now.getTime() + expiration); - - return Jwts.builder() - .subject(user.getEmail()) - .claim("userId", user.getId()) - .claim("role", user.getRole().name()) - .issuedAt(now) - .expiration(expiryDate) - .signWith(secretKey) - .compact(); - } - -} diff --git a/src/main/java/flipnote/user/service/UserService.java b/src/main/java/flipnote/user/service/UserService.java deleted file mode 100644 index bf16b20..0000000 --- a/src/main/java/flipnote/user/service/UserService.java +++ /dev/null @@ -1,59 +0,0 @@ -package flipnote.user.service; - -import flipnote.user.dto.LoginRequest; -import flipnote.user.dto.LoginResponse; -import flipnote.user.dto.SignupRequest; -import flipnote.user.dto.UserResponse; -import flipnote.user.entity.User; -import flipnote.user.exception.UserException; -import flipnote.user.repository.UserRepository; -import flipnote.user.security.JwtProvider; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class UserService { - - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - private final JwtProvider jwtProvider; - - @Transactional - public UserResponse signup(SignupRequest request) { - if (userRepository.existsByEmail(request.getEmail())) { - throw UserException.emailAlreadyExists(); - } - - User user = User.builder() - .email(request.getEmail()) - .password(passwordEncoder.encode(request.getPassword())) - .nickname(request.getNickname()) - .build(); - - User savedUser = userRepository.save(user); - return UserResponse.from(savedUser); - } - - public LoginResponse login(LoginRequest request) { - User user = userRepository.findByEmail(request.getEmail()) - .orElseThrow(UserException::invalidCredentials); - - if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { - throw UserException.invalidCredentials(); - } - - String token = jwtProvider.generateToken(user); - return LoginResponse.of(token, jwtProvider.getExpiration() / 1000); - } - - public UserResponse getProfile(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(UserException::userNotFound); - - return UserResponse.from(user); - } -} diff --git a/src/main/java/flipnote/user/user/application/UserService.java b/src/main/java/flipnote/user/user/application/UserService.java new file mode 100644 index 0000000..0461861 --- /dev/null +++ b/src/main/java/flipnote/user/user/application/UserService.java @@ -0,0 +1,56 @@ +package flipnote.user.user.application; + +import flipnote.user.auth.infrastructure.jwt.JwtProvider; +import flipnote.user.auth.infrastructure.redis.SessionInvalidationRepository; +import flipnote.user.global.exception.UserException; +import flipnote.user.user.domain.User; +import flipnote.user.user.domain.UserErrorCode; +import flipnote.user.user.domain.UserRepository; +import flipnote.user.user.presentation.dto.request.UpdateProfileRequest; +import flipnote.user.user.presentation.dto.response.MyInfoResponse; +import flipnote.user.user.presentation.dto.response.UserInfoResponse; +import flipnote.user.user.presentation.dto.response.UserUpdateResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + private final SessionInvalidationRepository sessionInvalidationRepository; + private final JwtProvider jwtProvider; + + public MyInfoResponse getMyInfo(Long userId) { + User user = findActiveUser(userId); + return MyInfoResponse.from(user); + } + + public UserInfoResponse getUserInfo(Long userId) { + User user = userRepository.findByIdAndStatus(userId, User.Status.ACTIVE) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + return UserInfoResponse.from(user); + } + + @Transactional + public UserUpdateResponse updateProfile(Long userId, UpdateProfileRequest request) { + User user = findActiveUser(userId); + user.updateProfile(request.getNickname(), request.getPhone(), + Boolean.TRUE.equals(request.getSmsAgree()), null); + return UserUpdateResponse.from(user); + } + + @Transactional + public void withdraw(Long userId) { + User user = findActiveUser(userId); + user.withdraw(); + sessionInvalidationRepository.invalidate(userId, jwtProvider.getRefreshTokenExpiration()); + } + + private User findActiveUser(Long userId) { + return userRepository.findByIdAndStatus(userId, User.Status.ACTIVE) + .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); + } +} diff --git a/src/main/java/flipnote/user/user/domain/OAuthLink.java b/src/main/java/flipnote/user/user/domain/OAuthLink.java new file mode 100644 index 0000000..893b542 --- /dev/null +++ b/src/main/java/flipnote/user/user/domain/OAuthLink.java @@ -0,0 +1,46 @@ +package flipnote.user.user.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "oauth_link", + indexes = { + @Index(name = "idx_oauth_provider_provider_id", columnList = "provider, providerId") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class OAuthLink { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String provider; + + @Column(nullable = false) + private String providerId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(updatable = false) + private LocalDateTime linkedAt; + + @Builder + public OAuthLink(String provider, String providerId, User user) { + this.provider = provider; + this.providerId = providerId; + this.user = user; + this.linkedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/flipnote/user/user/domain/OAuthLinkRepository.java b/src/main/java/flipnote/user/user/domain/OAuthLinkRepository.java new file mode 100644 index 0000000..fbc55c8 --- /dev/null +++ b/src/main/java/flipnote/user/user/domain/OAuthLinkRepository.java @@ -0,0 +1,27 @@ +package flipnote.user.user.domain; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface OAuthLinkRepository extends JpaRepository { + + @Query(""" + SELECT ol FROM OAuthLink ol + JOIN FETCH ol.user + WHERE ol.provider = :provider AND ol.providerId = :providerId + """) + Optional findByProviderAndProviderIdWithUser( + @Param("provider") String provider, + @Param("providerId") String providerId + ); + + boolean existsByUser_IdAndProviderAndProviderId(Long userId, String provider, String providerId); + + boolean existsByIdAndUser_Id(Long id, Long userId); + + List findByUser_Id(Long userId); +} diff --git a/src/main/java/flipnote/user/user/domain/User.java b/src/main/java/flipnote/user/user/domain/User.java new file mode 100644 index 0000000..ee76858 --- /dev/null +++ b/src/main/java/flipnote/user/user/domain/User.java @@ -0,0 +1,88 @@ +package flipnote.user.user.domain; + +import flipnote.user.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 100) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false, length = 50) + private String nickname; + + private String profileImageUrl; + + @Column(unique = true, length = 20) + private String phone; + + private boolean smsAgree; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Role role; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Status status; + + private LocalDateTime deletedAt; + + @Builder + public User(String email, String password, String name, String nickname, String phone, boolean smsAgree, Role role) { + this.email = email; + this.password = password; + this.name = name; + this.nickname = nickname; + this.phone = phone; + this.smsAgree = smsAgree; + this.role = role != null ? role : Role.USER; + this.status = Status.ACTIVE; + } + + public void changePassword(String encodedPassword) { + this.password = encodedPassword; + } + + public void updateProfile(String nickname, String phone, boolean smsAgree, String profileImageUrl) { + this.nickname = nickname; + this.phone = phone; + this.smsAgree = smsAgree; + if (profileImageUrl != null) { + this.profileImageUrl = profileImageUrl; + } + } + + public void withdraw() { + this.status = Status.WITHDRAWN; + this.deletedAt = LocalDateTime.now(); + } + + public enum Role { + USER, ADMIN + } + + public enum Status { + ACTIVE, WITHDRAWN + } +} diff --git a/src/main/java/flipnote/user/user/domain/UserErrorCode.java b/src/main/java/flipnote/user/user/domain/UserErrorCode.java new file mode 100644 index 0000000..d98aa0c --- /dev/null +++ b/src/main/java/flipnote/user/user/domain/UserErrorCode.java @@ -0,0 +1,22 @@ +package flipnote.user.user.domain; + +import flipnote.user.global.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode implements ErrorCode { + + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_001", "사용자를 찾을 수 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public int getStatus() { + return httpStatus.value(); + } +} diff --git a/src/main/java/flipnote/user/user/domain/UserRepository.java b/src/main/java/flipnote/user/user/domain/UserRepository.java new file mode 100644 index 0000000..e59c10d --- /dev/null +++ b/src/main/java/flipnote/user/user/domain/UserRepository.java @@ -0,0 +1,19 @@ +package flipnote.user.user.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); + + boolean existsByEmail(String email); + + Optional findByIdAndStatus(Long id, User.Status status); + + Optional findByEmailAndStatus(String email, User.Status status); + + List findByIdInAndStatus(List ids, User.Status status); +} diff --git a/src/main/java/flipnote/user/user/presentation/UserController.java b/src/main/java/flipnote/user/user/presentation/UserController.java new file mode 100644 index 0000000..fc53b14 --- /dev/null +++ b/src/main/java/flipnote/user/user/presentation/UserController.java @@ -0,0 +1,48 @@ +package flipnote.user.user.presentation; + +import flipnote.user.user.application.UserService; +import flipnote.user.user.presentation.dto.request.UpdateProfileRequest; +import flipnote.user.user.presentation.dto.response.MyInfoResponse; +import flipnote.user.user.presentation.dto.response.UserInfoResponse; +import flipnote.user.user.presentation.dto.response.UserUpdateResponse; +import flipnote.user.global.constants.HttpConstants; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/v1/users") +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @GetMapping("/me") + public ResponseEntity getMyInfo( + @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId) { + MyInfoResponse response = userService.getMyInfo(userId); + return ResponseEntity.ok(response); + } + + @GetMapping("/{userId}") + public ResponseEntity getUserInfo(@PathVariable Long userId) { + UserInfoResponse response = userService.getUserInfo(userId); + return ResponseEntity.ok(response); + } + + @PutMapping + public ResponseEntity updateProfile( + @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId, + @Valid @RequestBody UpdateProfileRequest request) { + UserUpdateResponse response = userService.updateProfile(userId, request); + return ResponseEntity.ok(response); + } + + @DeleteMapping + public ResponseEntity withdraw( + @RequestHeader(HttpConstants.USER_ID_HEADER) Long userId) { + userService.withdraw(userId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/flipnote/user/user/presentation/dto/request/UpdateProfileRequest.java b/src/main/java/flipnote/user/user/presentation/dto/request/UpdateProfileRequest.java new file mode 100644 index 0000000..2bcbff3 --- /dev/null +++ b/src/main/java/flipnote/user/user/presentation/dto/request/UpdateProfileRequest.java @@ -0,0 +1,23 @@ +package flipnote.user.user.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UpdateProfileRequest { + + @NotBlank(message = "닉네임은 필수입니다") + private String nickname; + + @Pattern(regexp = "^01[0-9]{8,9}$", message = "올바른 전화번호 형식이 아닙니다") + private String phone; + + @NotNull(message = "SMS 수신 동의 여부는 필수입니다") + private Boolean smsAgree; + + private Long imageRefId; +} diff --git a/src/main/java/flipnote/user/user/presentation/dto/response/MyInfoResponse.java b/src/main/java/flipnote/user/user/presentation/dto/response/MyInfoResponse.java new file mode 100644 index 0000000..0798946 --- /dev/null +++ b/src/main/java/flipnote/user/user/presentation/dto/response/MyInfoResponse.java @@ -0,0 +1,43 @@ +package flipnote.user.user.presentation.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import flipnote.user.user.domain.User; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class MyInfoResponse { + + private Long userId; + private String email; + private String nickname; + private String name; + private String phone; + private Boolean smsAgree; + private String profileImageUrl; + private Long imageRefId; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createdAt; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime modifiedAt; + + public static MyInfoResponse from(User user) { + return new MyInfoResponse( + user.getId(), + user.getEmail(), + user.getNickname(), + user.getName(), + user.getPhone(), + user.isSmsAgree(), + user.getProfileImageUrl(), + null, + user.getCreatedAt(), + user.getModifiedAt() + ); + } +} diff --git a/src/main/java/flipnote/user/user/presentation/dto/response/UserInfoResponse.java b/src/main/java/flipnote/user/user/presentation/dto/response/UserInfoResponse.java new file mode 100644 index 0000000..fd08f81 --- /dev/null +++ b/src/main/java/flipnote/user/user/presentation/dto/response/UserInfoResponse.java @@ -0,0 +1,19 @@ +package flipnote.user.user.presentation.dto.response; + +import flipnote.user.user.domain.User; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserInfoResponse { + + private Long userId; + private String nickname; + private String profileImageUrl; + private Long imageRefId; + + public static UserInfoResponse from(User user) { + return new UserInfoResponse(user.getId(), user.getNickname(), user.getProfileImageUrl(), null); + } +} diff --git a/src/main/java/flipnote/user/user/presentation/dto/response/UserUpdateResponse.java b/src/main/java/flipnote/user/user/presentation/dto/response/UserUpdateResponse.java new file mode 100644 index 0000000..05d24b8 --- /dev/null +++ b/src/main/java/flipnote/user/user/presentation/dto/response/UserUpdateResponse.java @@ -0,0 +1,28 @@ +package flipnote.user.user.presentation.dto.response; + +import flipnote.user.user.domain.User; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserUpdateResponse { + + private Long userId; + private String nickname; + private String phone; + private Boolean smsAgree; + private String profileImageUrl; + private Long imageRefId; + + public static UserUpdateResponse from(User user) { + return new UserUpdateResponse( + user.getId(), + user.getNickname(), + user.getPhone(), + user.isSmsAgree(), + user.getProfileImageUrl(), + null + ); + } +} diff --git a/src/main/java/flipnote/user/user/presentation/grpc/GrpcUserQueryService.java b/src/main/java/flipnote/user/user/presentation/grpc/GrpcUserQueryService.java new file mode 100644 index 0000000..54a49de --- /dev/null +++ b/src/main/java/flipnote/user/user/presentation/grpc/GrpcUserQueryService.java @@ -0,0 +1,72 @@ +package flipnote.user.user.presentation.grpc; + +import flipnote.user.user.domain.User; +import flipnote.user.user.domain.UserRepository; +import flipnote.user.grpc.GetUserRequest; +import flipnote.user.grpc.GetUserResponse; +import flipnote.user.grpc.GetUsersRequest; +import flipnote.user.grpc.GetUsersResponse; +import flipnote.user.grpc.UserQueryServiceGrpc; +import io.grpc.Status; +import io.grpc.stub.StreamObserver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GrpcUserQueryService extends UserQueryServiceGrpc.UserQueryServiceImplBase { + + private final UserRepository userRepository; + + @Override + public void getUser(GetUserRequest request, StreamObserver responseObserver) { + try { + User user = userRepository.findByIdAndStatus(request.getUserId(), User.Status.ACTIVE) + .orElse(null); + + if (user == null) { + responseObserver.onError( + Status.NOT_FOUND.withDescription("사용자를 찾을 수 없습니다.").asRuntimeException() + ); + return; + } + + responseObserver.onNext(toResponse(user)); + responseObserver.onCompleted(); + } catch (Exception e) { + log.error("gRPC getUser error. userId: {}", request.getUserId(), e); + responseObserver.onError(Status.INTERNAL.withDescription("Internal error").asRuntimeException()); + } + } + + @Override + public void getUsers(GetUsersRequest request, StreamObserver responseObserver) { + try { + List userIds = request.getUserIdsList(); + List users = userRepository.findByIdInAndStatus(userIds, User.Status.ACTIVE); + + GetUsersResponse response = GetUsersResponse.newBuilder() + .addAllUsers(users.stream().map(this::toResponse).toList()) + .build(); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + } catch (Exception e) { + log.error("gRPC getUsers error. userIds: {}", request.getUserIdsList(), e); + responseObserver.onError(Status.INTERNAL.withDescription("Internal error").asRuntimeException()); + } + } + + private GetUserResponse toResponse(User user) { + return GetUserResponse.newBuilder() + .setId(user.getId()) + .setEmail(user.getEmail()) + .setNickname(user.getNickname()) + .setProfileImageUrl(user.getProfileImageUrl() != null ? user.getProfileImageUrl() : "") + .build(); + } +} diff --git a/src/main/proto/user_query.proto b/src/main/proto/user_query.proto new file mode 100644 index 0000000..93b3afb --- /dev/null +++ b/src/main/proto/user_query.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; + +option java_package = "flipnote.user.grpc"; +option java_outer_classname = "UserQueryProto"; +option java_multiple_files = true; + +package user_query; + +service UserQueryService { + rpc GetUser(GetUserRequest) returns (GetUserResponse); + rpc GetUsers(GetUsersRequest) returns (GetUsersResponse); +} + +message GetUserRequest { + int64 user_id = 1; +} + +message GetUserResponse { + int64 id = 1; + string email = 2; + string nickname = 3; + string profile_image_url = 4; +} + +message GetUsersRequest { + repeated int64 user_ids = 1; +} + +message GetUsersResponse { + repeated GetUserResponse users = 1; +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a10bd58..c0a99fb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,6 +8,11 @@ spring: password: ${DB_PASSWORD:root} driver-class-name: com.mysql.cj.jdbc.Driver + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + jpa: hibernate: ddl-auto: ${DDL_AUTO:update} @@ -17,6 +22,10 @@ spring: format_sql: true dialect: org.hibernate.dialect.MySQLDialect + grpc: + server: + port: ${GRPC_PORT:9091} + server: port: 8081 @@ -36,5 +45,32 @@ management: enabled: true jwt: - secret: ${JWT_SECRET:"55ca298dcfc216e215622e3f48a251abaa4e8bb973074f065ab170e311acc15811d01a2407290c3ac143648196306d4a6f666a4ed364d3df633e08eb184bb0aea0f2edde4fd2d7fa68ea95ddbc421ff532ce47bde775975911042d665bc22d88a9fa26a03bb4d25530b8cdeb1247d87c9e3efcd721e368b0566b00a43308a729"} - expiration: ${JWT_EXPIRATION:86400000} + secret: ${JWT_SECRET} + access-token-expiration: ${JWT_ACCESS_EXPIRATION:900000} + refresh-token-expiration: ${JWT_REFRESH_EXPIRATION:604800000} + +app: + resend: + api-key: ${APP_RESEND_API_KEY} + from-email: FlipNote + client: + url: ${APP_CLIENT_URL:http://localhost:3000} + paths: + password-reset: /password-reset + social-link-success: /social-link/success + social-link-failure: /social-link/failure + social-link-conflict: /social-link/conflict + social-login-success: /social-login/success + social-login-failure: /social-login/failure + oauth2: + providers: + google: + client-id: ${GOOGLE_CLIENT_ID:} + client-secret: ${GOOGLE_CLIENT_SECRET:} + redirect-uri: /oauth2/callback/google + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo + scope: + - email + - profile diff --git a/src/main/resources/templates/email/email-verification.html b/src/main/resources/templates/email/email-verification.html new file mode 100644 index 0000000..69b9d41 --- /dev/null +++ b/src/main/resources/templates/email/email-verification.html @@ -0,0 +1,42 @@ + + + + + + + 이메일 인증 안내 + + +
+ 인증번호를 확인하고 이메일 인증을 완료해주세요. +
+ + + + + +
+ + + + + + + +
+

+ 이메일 인증번호 안내 +

+

+ 아래 인증번호를 입력해 주세요.
+ 인증번호는 5분간 유효합니다. +

+
+ 123456 +
+
+ 본 메일은 발신 전용입니다. +
+
+ + diff --git a/src/main/resources/templates/email/password-reset.html b/src/main/resources/templates/email/password-reset.html new file mode 100644 index 0000000..ff844b2 --- /dev/null +++ b/src/main/resources/templates/email/password-reset.html @@ -0,0 +1,44 @@ + + + + + + + 비밀번호 재설정 안내 + + +
+ 비밀번호 재설정을 위해 아래 버튼을 눌러주세요. +
+ + + + + +
+ + + + + + + +
+

+ 비밀번호 재설정 안내 +

+

+ 아래 버튼을 눌러 비밀번호를 재설정해 주세요.
+ 이 링크는 30분간 유효합니다. +

+ +
+ 본 메일은 발신 전용입니다. +
+
+ + diff --git a/src/test/java/flipnote/user/TestRedisConfig.java b/src/test/java/flipnote/user/TestRedisConfig.java new file mode 100644 index 0000000..d0706b7 --- /dev/null +++ b/src/test/java/flipnote/user/TestRedisConfig.java @@ -0,0 +1,21 @@ +package flipnote.user; + +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@TestConfiguration +public class TestRedisConfig { + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return Mockito.mock(RedisConnectionFactory.class); + } + + @Bean + public StringRedisTemplate stringRedisTemplate() { + return Mockito.mock(StringRedisTemplate.class); + } +} diff --git a/src/test/java/flipnote/user/UserApplicationTests.java b/src/test/java/flipnote/user/UserApplicationTests.java index 7c65307..0652f36 100644 --- a/src/test/java/flipnote/user/UserApplicationTests.java +++ b/src/test/java/flipnote/user/UserApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; @SpringBootTest +@Import(TestRedisConfig.class) class UserApplicationTests { @Test diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 74f0740..111c7df 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -14,6 +14,40 @@ spring: format_sql: true use_sql_comments: true + thymeleaf: + check-template-location: false + + grpc: + server: + port: 0 + jwt: secret: "55ca298dcfc216e215622e3f48a251abaa4e8bb973074f065ab170e311acc15811d01a2407290c3ac143648196306d4a6f666a4ed364d3df633e08eb184bb0aea0f2edde4fd2d7fa68ea95ddbc421ff532ce47bde775975911042d665bc22d88a9fa26a03bb4d25530b8cdeb1247d87c9e3efcd721e368b0566b00a43308a729" - expiration: 86400000 + access-token-expiration: 1800000 + refresh-token-expiration: 604800000 + +app: + resend: + api-key: test-api-key + from-email: test@test.com + client: + url: http://localhost:3000 + paths: + password-reset: /password-reset + social-link-success: /social-link/success + social-link-failure: /social-link/failure + social-link-conflict: /social-link/conflict + social-login-success: /social-login/success + social-login-failure: /social-login/failure + oauth2: + providers: + google: + client-id: test-client-id + client-secret: test-client-secret + redirect-uri: /oauth2/callback/google + authorization-uri: https://accounts.google.com/o/oauth2/v2/auth + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo + scope: + - email + - profile