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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,20 @@ DB_URL=jdbc:mysql://<host>:3306/<database>?serverTimezone=Asia/Seoul&characterEn
DB_USER=<username>
DB_PASSWORD=<password>
JPA_DDL_AUTO=validate

# 유튜브 Data API 키
YOUTUBE_API_KEY=<youtube-api-key>

# JWT 서명 키
JWT_SECRET=<jwt-secret>

# 구글 OAuth
GOOGLE_CLIENT_ID=<google-client-id>
GOOGLE_CLIENT_SECRET=<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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ logs/

### macOS ###
.DS_Store

### Docs ###
docs/
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/slatto/SlattoApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down
61 changes: 61 additions & 0 deletions src/main/java/com/slatto/domain/auth/client/GoogleOAuthClient.java
Original file line number Diff line number Diff line change
@@ -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();
Comment on lines +19 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C3 -g '*.java' -g '*.kt' \
  'RestClient\.builder|RestClient\.Builder|requestFactory|connectTimeout|readTimeout|setConnectTimeout|setReadTimeout' \
  src/main/java

rg -n -C3 -g '*.yml' -g '*.yaml' -g '*.properties' \
  'timeout|restclient|http' \
  src/main/resources

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 4652


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "== RestClient.Builder / RestClient customization =="
rg -n -C3 -g '*.java' -g '*.kt' \
  'RestClient\.Builder|RestClientCustomizer|RestClient.Builder|requestFactory|setConnectTimeout|setReadTimeout|connect-timeout|read-timeout|timeout' \
  src/main/java src/test/java

echo
echo "== Bean definitions likely affecting HTTP clients =="
rg -n -C3 -g '*.java' -g '*.kt' \
  '`@Bean`|`@Configuration`|ClientHttpRequestFactory|SimpleClientHttpRequestFactory|HttpComponentsClientHttpRequestFactory|OkHttp3ClientHttpRequestFactory' \
  src/main/java

echo
echo "== All auth-related config =="
sed -n '1,120p' src/main/resources/application.yml

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 12208


Google OAuth RestClient에 타임아웃을 추가하세요.

GoogleOAuthClientRestClient.Builder를 바로 build()만 하고 있어, Google 토큰/사용자정보 호출에 연결·응답 제한 시간이 없습니다. YoutubeApiClient처럼 이 클라이언트에도 connect/read timeout을 넣거나 공통 builder에 기본 타임아웃을 설정하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/auth/client/GoogleOAuthClient.java` around
lines 19 - 21, Update the GoogleOAuthClient constructor to configure connection
and response/read timeouts on the RestClient.Builder before build(), matching
the timeout behavior used by YoutubeApiClient or the project’s shared builder
defaults. Apply these settings to the restClient used for Google token and
user-information requests.

}

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<String, String> 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);
}

}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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);
}

}
109 changes: 109 additions & 0 deletions src/main/java/com/slatto/domain/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
@@ -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 리다이렉트한다. 인증이 필요 없다.

`<a href>` 또는 `window.location.href`로 **브라우저를 이동시켜** 호출한다.
fetch/ajax로 호출하는 API가 아니며, Swagger의 Try it out으로는 동작하지 않는다.

성공 시 302로 응답하며, 공통 응답 wrapper를 사용하지 않는다.
"""
)
@SecurityRequirements
@GetMapping("/login/google")
public ResponseEntity<Void> 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<Void> 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<AccessTokenResponse> 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<ApiResponse<Void>> 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.<Void>success(CommonSuccessCode.OK, null));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.slatto.domain.auth.dto;

public record AccessTokenResponse(String accessToken) {
}
50 changes: 50 additions & 0 deletions src/main/java/com/slatto/domain/auth/entity/RefreshToken.java
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

리프레시 토큰을 평문으로 저장하지 마세요.

Line 30은 재사용 가능한 bearer 토큰을 그대로 저장합니다. DB 읽기 권한 유출만으로 세션 탈취가 가능하므로, 서버 비밀키 기반 HMAC 다이제스트를 저장하고 조회·삭제 시에도 쿠키 토큰을 동일하게 다이제스트하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/auth/entity/RefreshToken.java` around lines
30 - 31, Update the RefreshToken token persistence flow around the token field
to store only an HMAC digest derived with a server-side secret, never the raw
bearer token. Apply the same deterministic digesting to incoming cookie tokens
before refresh-token lookup and deletion, while keeping token issuance and
client-facing cookie values unchanged.


@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);
}

}
23 changes: 23 additions & 0 deletions src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java
Original file line number Diff line number Diff line change
@@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -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<RefreshToken, Long> {

Optional<RefreshToken> findByToken(String token);

void deleteByToken(String token);

void deleteByUser(Users user);

}
Loading
Loading