diff --git a/.env.example b/.env.example index 30474164..59e1fb19 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,20 @@ DB_URL=jdbc:mysql://:3306/?serverTimezone=Asia/Seoul&characterEn DB_USER= DB_PASSWORD= JPA_DDL_AUTO=validate + +# 유튜브 Data API 키 +YOUTUBE_API_KEY= + +# JWT 서명 키 +JWT_SECRET= + +# 구글 OAuth +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=http://localhost:8080/api/v1/auth/callback/google + +# 로그인 완료 후 돌아갈 프론트엔드 주소 +FRONTEND_BASE_URL=http://localhost:3000 + +# 리프레시 토큰 쿠키의 Secure 속성. 로컬 http 테스트 시에만 false +COOKIE_SECURE=true diff --git a/.gitignore b/.gitignore index a3bd2fd3..6eb0cf64 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ logs/ ### macOS ### .DS_Store + +### Docs ### +docs/ diff --git a/build.gradle b/build.gradle index 1ddfea96..4948c1c1 100644 --- a/build.gradle +++ b/build.gradle @@ -20,16 +20,21 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16' implementation 'me.paulschwarz:spring-dotenv:4.0.0' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'com.h2database:h2' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/slatto/SlattoApplication.java b/src/main/java/com/slatto/SlattoApplication.java index f26b47a9..630cdd42 100644 --- a/src/main/java/com/slatto/SlattoApplication.java +++ b/src/main/java/com/slatto/SlattoApplication.java @@ -2,9 +2,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@ConfigurationPropertiesScan @EnableJpaAuditing public class SlattoApplication { diff --git a/src/main/java/com/slatto/domain/auth/client/GoogleOAuthClient.java b/src/main/java/com/slatto/domain/auth/client/GoogleOAuthClient.java new file mode 100644 index 00000000..357f2eb8 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/client/GoogleOAuthClient.java @@ -0,0 +1,61 @@ +package com.slatto.domain.auth.client; + +import com.slatto.domain.auth.client.dto.GoogleTokenResponse; +import com.slatto.domain.auth.client.dto.GoogleUserInfo; +import com.slatto.global.config.properties.GoogleOAuthProperties; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +public class GoogleOAuthClient { + + private final GoogleOAuthProperties googleOAuthProperties; + private final RestClient restClient; + + public GoogleOAuthClient(GoogleOAuthProperties googleOAuthProperties, RestClient.Builder restClientBuilder) { + this.googleOAuthProperties = googleOAuthProperties; + this.restClient = restClientBuilder.build(); + } + + public String buildAuthorizationUri(String state) { + return UriComponentsBuilder.fromUriString(googleOAuthProperties.authorizationUri()) + .queryParam("client_id", googleOAuthProperties.clientId()) + .queryParam("redirect_uri", googleOAuthProperties.redirectUri()) + .queryParam("response_type", "code") + .queryParam("scope", googleOAuthProperties.scope()) + .queryParam("state", state) + .queryParam("access_type", "offline") + .build() + .encode() + .toUriString(); + } + + public GoogleTokenResponse exchangeCodeForToken(String code) { + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("code", code); + form.add("client_id", googleOAuthProperties.clientId()); + form.add("client_secret", googleOAuthProperties.clientSecret()); + form.add("redirect_uri", googleOAuthProperties.redirectUri()); + form.add("grant_type", "authorization_code"); + + return restClient.post() + .uri(googleOAuthProperties.tokenUri()) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(form) + .retrieve() + .body(GoogleTokenResponse.class); + } + + public GoogleUserInfo fetchUserInfo(String accessToken) { + return restClient.get() + .uri(googleOAuthProperties.userInfoUri()) + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .body(GoogleUserInfo.class); + } + +} diff --git a/src/main/java/com/slatto/domain/auth/client/dto/GoogleTokenResponse.java b/src/main/java/com/slatto/domain/auth/client/dto/GoogleTokenResponse.java new file mode 100644 index 00000000..10c73f46 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/client/dto/GoogleTokenResponse.java @@ -0,0 +1,10 @@ +package com.slatto.domain.auth.client.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleTokenResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("expires_in") Long expiresIn, + @JsonProperty("token_type") String tokenType +) { +} diff --git a/src/main/java/com/slatto/domain/auth/client/dto/GoogleUserInfo.java b/src/main/java/com/slatto/domain/auth/client/dto/GoogleUserInfo.java new file mode 100644 index 00000000..ab011e63 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/client/dto/GoogleUserInfo.java @@ -0,0 +1,17 @@ +package com.slatto.domain.auth.client.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleUserInfo( + String sub, + String email, + @JsonProperty("email_verified") Boolean emailVerified, + String name, + String picture +) { + + public boolean isEmailVerified() { + return Boolean.TRUE.equals(emailVerified); + } + +} diff --git a/src/main/java/com/slatto/domain/auth/controller/AuthController.java b/src/main/java/com/slatto/domain/auth/controller/AuthController.java new file mode 100644 index 00000000..0b54ee57 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/controller/AuthController.java @@ -0,0 +1,109 @@ +package com.slatto.domain.auth.controller; + +import com.slatto.domain.auth.dto.AccessTokenResponse; +import com.slatto.domain.auth.service.AuthService; +import com.slatto.domain.auth.support.AuthCookieFactory; +import com.slatto.global.response.ApiResponse; +import com.slatto.global.response.code.CommonSuccessCode; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; + +@Tag(name = "Auth", description = "인증 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth") +public class AuthController { + + private final AuthService authService; + private final AuthCookieFactory authCookieFactory; + + @Operation( + summary = "구글 로그인 진입", + description = """ + 구글 인증 페이지로 302 리다이렉트한다. 인증이 필요 없다. + + `` 또는 `window.location.href`로 **브라우저를 이동시켜** 호출한다. + fetch/ajax로 호출하는 API가 아니며, Swagger의 Try it out으로는 동작하지 않는다. + + 성공 시 302로 응답하며, 공통 응답 wrapper를 사용하지 않는다. + """ + ) + @SecurityRequirements + @GetMapping("/login/google") + public ResponseEntity loginWithGoogle( + @RequestParam(name = "redirectTo", required = false) String redirectTo + ) { + AuthService.GoogleLoginEntry entry = authService.createGoogleLoginEntry(redirectTo); + + return ResponseEntity + .status(302) + .header(HttpHeaders.SET_COOKIE, authCookieFactory.oauthState(entry.state().toCookieValue()).toString()) + .location(URI.create(entry.authorizationUri())) + .build(); + } + + @Hidden + @GetMapping("/callback/google") + public ResponseEntity handleGoogleCallback( + @RequestParam(name = "code", required = false) String code, + @RequestParam(name = "state", required = false) String state, + @RequestParam(name = "error", required = false) String error, + @CookieValue(name = "${app.cookie.oauth-state-name}", required = false) String stateCookie + ) { + AuthService.GoogleCallbackResult result = authService.handleGoogleCallback(code, state, error, stateCookie); + + ResponseEntity.BodyBuilder builder = ResponseEntity + .status(302) + .header(HttpHeaders.SET_COOKIE, authCookieFactory.expiredOauthState().toString()); + + if (result.isSuccess()) { + builder.header( + HttpHeaders.SET_COOKIE, + authCookieFactory.refreshToken(result.refreshToken(), result.refreshTokenMaxAgeSeconds()).toString() + ); + } + + return builder + .location(URI.create(result.redirectUri())) + .build(); + } + + @Operation( + summary = "액세스 토큰 재발급", + description = "쿠키의 리프레시 토큰으로 새 액세스 토큰을 발급한다. 요청 본문과 Authorization 헤더가 모두 필요 없다." + ) + @SecurityRequirements + @PostMapping("/refresh") + public ApiResponse reissueAccessToken( + @CookieValue(name = "${app.cookie.refresh-token-name}", required = false) String refreshToken + ) { + return ApiResponse.success(CommonSuccessCode.OK, authService.reissueAccessToken(refreshToken)); + } + + @Operation(summary = "로그아웃", description = "서버에 저장된 리프레시 토큰을 무효화하고 쿠키를 삭제한다.") + @PostMapping("/logout") + public ResponseEntity> logout( + @CookieValue(name = "${app.cookie.refresh-token-name}", required = false) String refreshToken + ) { + authService.logout(refreshToken); + + return ResponseEntity + .ok() + .header(HttpHeaders.SET_COOKIE, authCookieFactory.expiredRefreshToken().toString()) + .body(ApiResponse.success(CommonSuccessCode.OK, null)); + } + +} diff --git a/src/main/java/com/slatto/domain/auth/dto/AccessTokenResponse.java b/src/main/java/com/slatto/domain/auth/dto/AccessTokenResponse.java new file mode 100644 index 00000000..eec9872f --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/dto/AccessTokenResponse.java @@ -0,0 +1,4 @@ +package com.slatto.domain.auth.dto; + +public record AccessTokenResponse(String accessToken) { +} diff --git a/src/main/java/com/slatto/domain/auth/entity/RefreshToken.java b/src/main/java/com/slatto/domain/auth/entity/RefreshToken.java new file mode 100644 index 00000000..4bf28bdd --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/entity/RefreshToken.java @@ -0,0 +1,50 @@ +package com.slatto.domain.auth.entity; + +import com.slatto.domain.common.entity.BaseEntity; +import com.slatto.domain.user.entity.Users; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "refresh_token", + indexes = @Index(name = "idx_refresh_token_user_id", columnList = "user_id") +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private Users user; + + @Column(name = "token", nullable = false, length = 512, unique = true) + private String token; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + private RefreshToken(Users user, String token, LocalDateTime expiresAt) { + this.user = user; + this.token = token; + this.expiresAt = expiresAt; + } + + public static RefreshToken issue(Users user, String token, LocalDateTime expiresAt) { + return new RefreshToken(user, token, expiresAt); + } + + public boolean isExpired(LocalDateTime now) { + return expiresAt.isBefore(now); + } + +} diff --git a/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java new file mode 100644 index 00000000..6d401afb --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java @@ -0,0 +1,23 @@ +package com.slatto.domain.auth.exception; + +import com.slatto.global.response.code.BaseCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements BaseCode { + + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH401", "리프레시 토큰이 만료되었거나 유효하지 않습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public boolean isSuccess() { + return false; + } + +} diff --git a/src/main/java/com/slatto/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/slatto/domain/auth/repository/RefreshTokenRepository.java new file mode 100644 index 00000000..ec84c0cb --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,19 @@ +package com.slatto.domain.auth.repository; + +import com.slatto.domain.auth.entity.RefreshToken; +import com.slatto.domain.user.entity.Users; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + void deleteByToken(String token); + + void deleteByUser(Users user); + +} diff --git a/src/main/java/com/slatto/domain/auth/service/AuthService.java b/src/main/java/com/slatto/domain/auth/service/AuthService.java new file mode 100644 index 00000000..da43353a --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/service/AuthService.java @@ -0,0 +1,154 @@ +package com.slatto.domain.auth.service; + +import com.slatto.domain.auth.client.GoogleOAuthClient; +import com.slatto.domain.auth.client.dto.GoogleTokenResponse; +import com.slatto.domain.auth.client.dto.GoogleUserInfo; +import com.slatto.domain.auth.dto.AccessTokenResponse; +import com.slatto.domain.auth.entity.RefreshToken; +import com.slatto.domain.auth.exception.AuthErrorCode; +import com.slatto.domain.auth.repository.RefreshTokenRepository; +import com.slatto.domain.auth.support.GoogleAuthFailureReason; +import com.slatto.domain.auth.support.OAuthState; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.SocialType; +import com.slatto.domain.user.repository.UserRepository; +import com.slatto.global.config.properties.FrontendProperties; +import com.slatto.global.exception.BaseException; +import com.slatto.global.security.JwtTokenProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AuthService { + + private final GoogleOAuthClient googleOAuthClient; + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenProvider jwtTokenProvider; + private final FrontendProperties frontendProperties; + + public GoogleLoginEntry createGoogleLoginEntry(String redirectTo) { + OAuthState state = OAuthState.create(frontendProperties.resolveRedirectPath(redirectTo)); + + return new GoogleLoginEntry(googleOAuthClient.buildAuthorizationUri(state.value()), state); + } + + @Transactional + public GoogleCallbackResult handleGoogleCallback( + String code, + String state, + String error, + String stateCookieValue + ) { + if (error != null) { + return failure(GoogleAuthFailureReason.ACCESS_DENIED); + } + + OAuthState storedState = stateCookieValue == null ? null : OAuthState.fromCookieValue(stateCookieValue); + + if (code == null || state == null || storedState == null || !storedState.value().equals(state)) { + return failure(GoogleAuthFailureReason.INVALID_STATE); + } + + GoogleUserInfo userInfo; + + try { + GoogleTokenResponse token = googleOAuthClient.exchangeCodeForToken(code); + userInfo = googleOAuthClient.fetchUserInfo(token.accessToken()); + } catch (Exception exception) { + log.warn("[Google OAuth] 인가 코드 교환 또는 프로필 조회 실패", exception); + return failure(GoogleAuthFailureReason.AUTH_FAILED); + } + + if (userInfo == null || userInfo.email() == null || !userInfo.isEmailVerified()) { + return failure(GoogleAuthFailureReason.AUTH_FAILED); + } + + Users user = findOrCreateUser(userInfo); + String refreshToken = issueRefreshToken(user); + + return new GoogleCallbackResult( + frontendProperties.toAbsoluteUrl(storedState.redirectPath()), + refreshToken, + jwtTokenProvider.refreshTokenMaxAgeSeconds() + ); + } + + // TODO: 리프레시 토큰 회전(rotation) 도입 시 여기서 기존 토큰을 폐기하고 새 토큰을 발급해 + // AccessTokenResponse와 함께 Set-Cookie로 다시 내려줘야 한다. + @Transactional(readOnly = true) + public AccessTokenResponse reissueAccessToken(String refreshTokenValue) { + if (refreshTokenValue == null) { + throw new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + + RefreshToken storedToken = refreshTokenRepository.findByToken(refreshTokenValue) + .orElseThrow(() -> new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN)); + + Long userId = jwtTokenProvider.parseUserId(refreshTokenValue, true); + + if (userId == null || storedToken.isExpired(LocalDateTime.now())) { + throw new BaseException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + + return new AccessTokenResponse(jwtTokenProvider.createAccessToken(userId)); + } + + @Transactional + public void logout(String refreshTokenValue) { + if (refreshTokenValue != null) { + refreshTokenRepository.deleteByToken(refreshTokenValue); + } + } + + private Users findOrCreateUser(GoogleUserInfo userInfo) { + return userRepository.findBySocialTypeAndSocialId(SocialType.GOOGLE, userInfo.sub()) + .or(() -> userRepository.findByEmail(userInfo.email()) + .map(existing -> { + existing.linkSocialAccount(SocialType.GOOGLE, userInfo.sub()); + return existing; + })) + .orElseGet(() -> userRepository.save(Users.createSocialUser( + userInfo.email(), + userInfo.name(), + userInfo.picture(), + SocialType.GOOGLE, + userInfo.sub() + ))); + } + + private String issueRefreshToken(Users user) { + refreshTokenRepository.deleteByUser(user); + + String token = jwtTokenProvider.createRefreshToken(user.getId()); + refreshTokenRepository.save(RefreshToken.issue(user, token, jwtTokenProvider.refreshTokenExpiresAt())); + + return token; + } + + private GoogleCallbackResult failure(GoogleAuthFailureReason reason) { + String redirectUri = frontendProperties.toAbsoluteUrl(frontendProperties.errorPath()) + + "?reason=" + reason.name(); + + return new GoogleCallbackResult(redirectUri, null, 0); + } + + public record GoogleLoginEntry(String authorizationUri, OAuthState state) { + } + + public record GoogleCallbackResult(String redirectUri, String refreshToken, long refreshTokenMaxAgeSeconds) { + + public boolean isSuccess() { + return refreshToken != null; + } + + } + +} diff --git a/src/main/java/com/slatto/domain/auth/support/AuthCookieFactory.java b/src/main/java/com/slatto/domain/auth/support/AuthCookieFactory.java new file mode 100644 index 00000000..80c13a39 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/support/AuthCookieFactory.java @@ -0,0 +1,66 @@ +package com.slatto.domain.auth.support; + +import com.slatto.global.config.properties.CookieProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class AuthCookieFactory { + + private final CookieProperties cookieProperties; + + public ResponseCookie refreshToken(String token, long maxAgeSeconds) { + return base(cookieProperties.refreshTokenName(), token) + .maxAge(maxAgeSeconds) + .build(); + } + + public ResponseCookie expiredRefreshToken() { + return base(cookieProperties.refreshTokenName(), "") + .maxAge(0) + .build(); + } + + public ResponseCookie oauthState(String state) { + return oauthStateBase(state) + .maxAge(cookieProperties.oauthStateMaxAge()) + .build(); + } + + public ResponseCookie expiredOauthState() { + return oauthStateBase("") + .maxAge(0) + .build(); + } + + private ResponseCookie.ResponseCookieBuilder base(String name, String value) { + return ResponseCookie.from(name, value) + .httpOnly(true) + .secure(cookieProperties.secure()) + .path(cookieProperties.path()) + .sameSite(cookieProperties.sameSite()); + } + + // 구글에서 콜백으로 돌아오는 요청은 크로스 사이트라 SameSite=Strict면 쿠키가 실리지 않는다. + private ResponseCookie.ResponseCookieBuilder oauthStateBase(String value) { + return base(cookieProperties.oauthStateName(), value) + .sameSite(cookieProperties.oauthStateSameSite()); + } + + public String refreshTokenName() { + return cookieProperties.refreshTokenName(); + } + + public String oauthStateName() { + return cookieProperties.oauthStateName(); + } + + public Duration oauthStateMaxAge() { + return cookieProperties.oauthStateMaxAge(); + } + +} diff --git a/src/main/java/com/slatto/domain/auth/support/GoogleAuthFailureReason.java b/src/main/java/com/slatto/domain/auth/support/GoogleAuthFailureReason.java new file mode 100644 index 00000000..268c091d --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/support/GoogleAuthFailureReason.java @@ -0,0 +1,9 @@ +package com.slatto.domain.auth.support; + +public enum GoogleAuthFailureReason { + + ACCESS_DENIED, + INVALID_STATE, + AUTH_FAILED + +} diff --git a/src/main/java/com/slatto/domain/auth/support/OAuthState.java b/src/main/java/com/slatto/domain/auth/support/OAuthState.java new file mode 100644 index 00000000..f6544cd8 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/support/OAuthState.java @@ -0,0 +1,32 @@ +package com.slatto.domain.auth.support; + +import java.security.SecureRandom; +import java.util.Base64; + +public record OAuthState(String value, String redirectPath) { + + private static final String DELIMITER = "|"; + private static final SecureRandom RANDOM = new SecureRandom(); + + public static OAuthState create(String redirectPath) { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + + return new OAuthState(Base64.getUrlEncoder().withoutPadding().encodeToString(bytes), redirectPath); + } + + public String toCookieValue() { + return value + DELIMITER + redirectPath; + } + + public static OAuthState fromCookieValue(String cookieValue) { + int index = cookieValue.indexOf(DELIMITER); + + if (index < 0) { + return null; + } + + return new OAuthState(cookieValue.substring(0, index), cookieValue.substring(index + 1)); + } + +} diff --git a/src/main/java/com/slatto/domain/user/entity/Users.java b/src/main/java/com/slatto/domain/user/entity/Users.java index f0e604e4..d8cc4ddf 100644 --- a/src/main/java/com/slatto/domain/user/entity/Users.java +++ b/src/main/java/com/slatto/domain/user/entity/Users.java @@ -48,5 +48,32 @@ public class Users extends BaseEntity{ @Column(name = "term", nullable = false) private Boolean term; + @Column(name = "onboarding_completed", nullable = false) + private Boolean onboardingCompleted; + + private Users(String email, String nickname, String profileImageUrl, SocialType socialType, String socialId) { + this.email = email; + this.nickname = nickname; + this.profileImageUrl = profileImageUrl; + this.socialType = socialType; + this.socialId = socialId; + this.term = false; + this.onboardingCompleted = false; + } + + public static Users createSocialUser( + String email, + String nickname, + String profileImageUrl, + SocialType socialType, + String socialId + ) { + return new Users(email, nickname, profileImageUrl, socialType, socialId); + } + + public void linkSocialAccount(SocialType socialType, String socialId) { + this.socialType = socialType; + this.socialId = socialId; + } } \ No newline at end of file diff --git a/src/main/java/com/slatto/domain/user/repository/UserRepository.java b/src/main/java/com/slatto/domain/user/repository/UserRepository.java index 062dee1d..d2daac57 100644 --- a/src/main/java/com/slatto/domain/user/repository/UserRepository.java +++ b/src/main/java/com/slatto/domain/user/repository/UserRepository.java @@ -1,6 +1,7 @@ package com.slatto.domain.user.repository; import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.SocialType; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; @@ -10,4 +11,8 @@ public interface UserRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); boolean existsByIdAndDeletedAtIsNull(Long id); + + Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); + + Optional findByEmail(String email); } diff --git a/src/main/java/com/slatto/global/config/SecurityConfig.java b/src/main/java/com/slatto/global/config/SecurityConfig.java new file mode 100644 index 00000000..02c1772d --- /dev/null +++ b/src/main/java/com/slatto/global/config/SecurityConfig.java @@ -0,0 +1,44 @@ +package com.slatto.global.config; + +import com.slatto.global.security.JwtAuthenticationEntryPoint; +import com.slatto.global.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(csrf -> csrf.disable()) + .formLogin(formLogin -> formLogin.disable()) + .httpBasic(httpBasic -> httpBasic.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/v1/auth/login/**", + "/api/v1/auth/callback/**", + "/api/v1/auth/refresh" + ).permitAll() + .requestMatchers(HttpMethod.GET, "/api/v1/health", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .anyRequest().authenticated() + ) + .exceptionHandling(handling -> handling.authenticationEntryPoint(jwtAuthenticationEntryPoint)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + +} diff --git a/src/main/java/com/slatto/global/config/SwaggerConfig.java b/src/main/java/com/slatto/global/config/SwaggerConfig.java index 2aa007fb..abf2f1b6 100644 --- a/src/main/java/com/slatto/global/config/SwaggerConfig.java +++ b/src/main/java/com/slatto/global/config/SwaggerConfig.java @@ -1,19 +1,29 @@ package com.slatto.global.config; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SwaggerConfig { + private static final String BEARER_AUTH = "bearerAuth"; + @Bean public OpenAPI openAPI() { return new OpenAPI() .info(new Info() .title("SLAT-TO Backend API") .description("SLAT-TO backend API documentation") - .version("v1")); + .version("v1")) + .components(new Components().addSecuritySchemes(BEARER_AUTH, new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))) + .addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH)); } } diff --git a/src/main/java/com/slatto/global/config/properties/CookieProperties.java b/src/main/java/com/slatto/global/config/properties/CookieProperties.java new file mode 100644 index 00000000..63717c09 --- /dev/null +++ b/src/main/java/com/slatto/global/config/properties/CookieProperties.java @@ -0,0 +1,17 @@ +package com.slatto.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "app.cookie") +public record CookieProperties( + String refreshTokenName, + String oauthStateName, + String path, + boolean secure, + String sameSite, + String oauthStateSameSite, + Duration oauthStateMaxAge +) { +} diff --git a/src/main/java/com/slatto/global/config/properties/FrontendProperties.java b/src/main/java/com/slatto/global/config/properties/FrontendProperties.java new file mode 100644 index 00000000..6222d88c --- /dev/null +++ b/src/main/java/com/slatto/global/config/properties/FrontendProperties.java @@ -0,0 +1,27 @@ +package com.slatto.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +@ConfigurationProperties(prefix = "app.frontend") +public record FrontendProperties( + String baseUrl, + String callbackPath, + String errorPath, + List allowedRedirectPaths +) { + + public String resolveRedirectPath(String redirectTo) { + if (redirectTo != null && allowedRedirectPaths.contains(redirectTo)) { + return redirectTo; + } + + return callbackPath; + } + + public String toAbsoluteUrl(String path) { + return baseUrl + path; + } + +} diff --git a/src/main/java/com/slatto/global/config/properties/GoogleOAuthProperties.java b/src/main/java/com/slatto/global/config/properties/GoogleOAuthProperties.java new file mode 100644 index 00000000..c45e901d --- /dev/null +++ b/src/main/java/com/slatto/global/config/properties/GoogleOAuthProperties.java @@ -0,0 +1,15 @@ +package com.slatto.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.oauth.google") +public record GoogleOAuthProperties( + String clientId, + String clientSecret, + String redirectUri, + String authorizationUri, + String tokenUri, + String userInfoUri, + String scope +) { +} diff --git a/src/main/java/com/slatto/global/config/properties/JwtProperties.java b/src/main/java/com/slatto/global/config/properties/JwtProperties.java new file mode 100644 index 00000000..6366ec35 --- /dev/null +++ b/src/main/java/com/slatto/global/config/properties/JwtProperties.java @@ -0,0 +1,14 @@ +package com.slatto.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "app.jwt") +public record JwtProperties( + String secret, + String issuer, + Duration accessTokenValidity, + Duration refreshTokenValidity +) { +} diff --git a/src/main/java/com/slatto/global/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/slatto/global/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..d74e974f --- /dev/null +++ b/src/main/java/com/slatto/global/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,37 @@ +package com.slatto.global.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.slatto.global.response.ApiResponse; +import com.slatto.global.response.code.CommonErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + CommonErrorCode errorCode = CommonErrorCode.UNAUTHORIZED; + + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + objectMapper.writeValue(response.getWriter(), ApiResponse.failure(errorCode)); + } + +} diff --git a/src/main/java/com/slatto/global/security/JwtAuthenticationFilter.java b/src/main/java/com/slatto/global/security/JwtAuthenticationFilter.java new file mode 100644 index 00000000..ad55b5bd --- /dev/null +++ b/src/main/java/com/slatto/global/security/JwtAuthenticationFilter.java @@ -0,0 +1,59 @@ +package com.slatto.global.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final String BEARER_PREFIX = "Bearer "; + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + String token = resolveToken(request); + + if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) { + Long userId = jwtTokenProvider.parseUserId(token, false); + + if (userId != null) { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userId, null, List.of() + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (header != null && header.startsWith(BEARER_PREFIX)) { + return header.substring(BEARER_PREFIX.length()); + } + + return null; + } + +} diff --git a/src/main/java/com/slatto/global/security/JwtTokenProvider.java b/src/main/java/com/slatto/global/security/JwtTokenProvider.java new file mode 100644 index 00000000..e896fb7e --- /dev/null +++ b/src/main/java/com/slatto/global/security/JwtTokenProvider.java @@ -0,0 +1,84 @@ +package com.slatto.global.security; + +import com.slatto.global.config.properties.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Date; + +@Slf4j +@Component +public class JwtTokenProvider { + + private static final String CLAIM_TOKEN_TYPE = "type"; + private static final String TOKEN_TYPE_ACCESS = "access"; + private static final String TOKEN_TYPE_REFRESH = "refresh"; + + private final JwtProperties jwtProperties; + private final SecretKey secretKey; + + public JwtTokenProvider(JwtProperties jwtProperties) { + this.jwtProperties = jwtProperties; + this.secretKey = Keys.hmacShaKeyFor(jwtProperties.secret().getBytes(StandardCharsets.UTF_8)); + } + + public String createAccessToken(Long userId) { + return createToken(userId, TOKEN_TYPE_ACCESS, jwtProperties.accessTokenValidity().toMillis()); + } + + public String createRefreshToken(Long userId) { + return createToken(userId, TOKEN_TYPE_REFRESH, jwtProperties.refreshTokenValidity().toMillis()); + } + + private String createToken(Long userId, String tokenType, long validityInMillis) { + Date issuedAt = new Date(); + Date expiration = new Date(issuedAt.getTime() + validityInMillis); + + return Jwts.builder() + .issuer(jwtProperties.issuer()) + .subject(String.valueOf(userId)) + .claim(CLAIM_TOKEN_TYPE, tokenType) + .issuedAt(issuedAt) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } + + public Long parseUserId(String token, boolean refreshToken) { + try { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .requireIssuer(jwtProperties.issuer()) + .require(CLAIM_TOKEN_TYPE, refreshToken ? TOKEN_TYPE_REFRESH : TOKEN_TYPE_ACCESS) + .build() + .parseSignedClaims(token) + .getPayload(); + + return Long.valueOf(claims.getSubject()); + } catch (JwtException | IllegalArgumentException exception) { + log.debug("[JWT] 토큰 검증 실패: {}", exception.getMessage()); + return null; + } + } + + public LocalDateTime refreshTokenExpiresAt() { + return LocalDateTime.ofInstant( + Instant.now().plusMillis(jwtProperties.refreshTokenValidity().toMillis()), + ZoneId.systemDefault() + ); + } + + public long refreshTokenMaxAgeSeconds() { + return jwtProperties.refreshTokenValidity().toSeconds(); + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b4eea978..de783ce6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -29,3 +29,40 @@ youtube: key: ${YOUTUBE_API_KEY} connect-timeout: ${YOUTUBE_API_CONNECT_TIMEOUT:3s} read-timeout: ${YOUTUBE_API_READ_TIMEOUT:5s} + +app: + jwt: + secret: ${JWT_SECRET} + issuer: slatto + access-token-validity: PT1H + refresh-token-validity: P14D + + oauth: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + redirect-uri: ${GOOGLE_REDIRECT_URI} + 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: openid email profile + + frontend: + base-url: ${FRONTEND_BASE_URL} + callback-path: /auth/callback + error-path: /auth/error + allowed-redirect-paths: + - / + - /auth/callback + - /onboarding + + cookie: + refresh-token-name: refreshToken + oauth-state-name: oauthState + path: /api/v1/auth + # 로컬 http 환경에서만 false로 내린다. 배포 환경은 반드시 true 유지. + secure: ${COOKIE_SECURE:true} + # TODO: 배포 도메인 확정 후 SameSite=Lax 완화 여부 결정 (docs/auth-api.md 미결정 사항) + same-site: Strict + oauth-state-same-site: Lax + oauth-state-max-age: PT5M diff --git a/src/main/resources/db/migration/001-auth-google-login.sql b/src/main/resources/db/migration/001-auth-google-login.sql new file mode 100644 index 00000000..90a16856 --- /dev/null +++ b/src/main/resources/db/migration/001-auth-google-login.sql @@ -0,0 +1,19 @@ +-- ddl-auto=validate 이므로 애플리케이션 기동 전에 직접 적용해야 한다. + +ALTER TABLE users + ADD COLUMN onboarding_completed BIT(1) NOT NULL DEFAULT b'0'; + +CREATE TABLE refresh_token +( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + token VARCHAR(512) NOT NULL, + expires_at DATETIME(6) NOT NULL, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_refresh_token_token (token), + KEY idx_refresh_token_user_id (user_id), + CONSTRAINT fk_refresh_token_user FOREIGN KEY (user_id) REFERENCES users (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4; diff --git a/src/test/java/com/slatto/SlattoApplicationTests.java b/src/test/java/com/slatto/SlattoApplicationTests.java index 1afed2ba..46f91a72 100644 --- a/src/test/java/com/slatto/SlattoApplicationTests.java +++ b/src/test/java/com/slatto/SlattoApplicationTests.java @@ -3,11 +3,7 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest(properties = { - "spring.autoconfigure.exclude=" - + "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration," - + "org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration" -}) +@SpringBootTest class SlattoApplicationTests { @Test diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..2756f893 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,53 @@ +spring: + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 + username: sa + password: + + jpa: + database: h2 + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: create-drop + +youtube: + api: + key: test-youtube-api-key + connect-timeout: 3s + read-timeout: 5s + +app: + jwt: + secret: test-secret-key-for-context-load-only-32bytes + issuer: slatto + access-token-validity: PT30M + refresh-token-validity: P14D + + oauth: + google: + client-id: test-client-id + client-secret: test-client-secret + redirect-uri: http://localhost:8080/api/v1/auth/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: openid email profile + + frontend: + base-url: http://localhost:3000 + callback-path: /auth/callback + error-path: /auth/error + allowed-redirect-paths: + - / + - /auth/callback + - /onboarding + + cookie: + refresh-token-name: refreshToken + oauth-state-name: oauthState + path: /api/v1/auth + secure: true + same-site: Strict + oauth-state-same-site: Lax + oauth-state-max-age: PT5M