Skip to content
Merged
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ build/
!**/src/test/**/build/

*.yml
*.p12
!docker-compose**

!.github/**
Expand Down Expand Up @@ -39,4 +40,8 @@ out/
/.nb-gradle/

### VS Code ###
.vscode/
.vscode/




148 changes: 148 additions & 0 deletions docs/infra/local-https-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# 🔒 로컬 HTTPS 개발 환경 구성 (`local.juulabel.com`)

Next.js 프론트엔드와 Spring Boot 백엔드를 위한 로컬 HTTPS 개발 환경 설정 가이드입니다. `https://local.juulabel.com` 도메인을 기준으로 양쪽 환경을 통합합니다.

---

## 🧩 1. 공통 호스트 설정

### ✅ `/etc/hosts` 파일 수정

- **macOS / Linux**:

```bash
sudo nano /etc/hosts
```

- **Windows**:
```
C:\Windows\System32\drivers\etc\hosts
```

#### 📌 내용 추가:

```
127.0.0.1 local.juulabel.com
```

---

## 🔐 2. HTTPS 인증서 생성

### ✅ `mkcert` 설치 후 인증서 생성:

```bash
mkcert local.juulabel.com
```

생성된 파일:

- `local.juulabel.com.pem` – 인증서
- `local.juulabel.com-key.pem` – 개인 키

---

## 🧭 프론트엔드 (Next.js)

### 📁 프로젝트 루트에 파일 배치:

```
juulabel-front/
├── local.juulabel.com.pem
├── local.juulabel.com-key.pem
├── server.cjs
```

### 🧾 `server.cjs`

```js
const { createServer } = require("https");
const { parse } = require("url");
const next = require("next");
const fs = require("fs");
const path = require("path");

const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();

const httpsOptions = {
key: fs.readFileSync(path.join(__dirname, "local.juulabel.com-key.pem")),
cert: fs.readFileSync(path.join(__dirname, "local.juulabel.com.pem")),
};

app.prepare().then(() => {
createServer(httpsOptions, (req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
}).listen(3000, () => {
console.log("✅ App running at https://local.juulabel.com:3000");
});
});
```

### 📜 `package.json` 설정

```json
"scripts": {
"dev:https": "node server.cjs"
}
```

실행:

```bash
pnpm run dev:https
```

---

## 🛡 백엔드 (Spring Boot)

### 📦 인증서 변환 (PKCS12)

```bash
openssl pkcs12 -export \
-in local.juulabel.com.pem \
-inkey local.juulabel.com-key.pem \
-out local.juulabel.com.p12 \
-name local-ssl
```

### 📁 keystore 파일 위치

`local.juulabel.com.p12` → `src/main/resources` 디렉토리에 복사

---

### ⚙️ `application.yml` 설정

```yaml
server:
port: 8080
ssl:
enabled: true
key-store: classpath:local.juulabel.com.p12
key-store-password: your_password
key-store-type: PKCS12
```

---

## ✅ 결과 요약

| 항목 | 주소 |
| ---------- | ----------------------------------------- |
| 프론트엔드 | `https://local.juulabel.com:3000` |
| 백엔드 | `https://local.juulabel.com:8080` |
| 쿠키 | Secure + SameSite=None + 동일 도메인 필요 |

---

## 📎 참고 사항

- 소셜 로그인 리디렉션 URI도 `https://local.juulabel.com` 기준으로 등록해야 합니다.
- 브라우저가 쿠키를 허용하려면:
- `Secure: true`
- `SameSite: None`
- 도메인 일치 필요
8 changes: 4 additions & 4 deletions src/main/java/com/juu/juulabel/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,16 @@ public void deleteAccount(Member member, WithdrawalRequest request) {

private void handleNewMember(OAuthUser oAuthUser) {
String nonce = memberCreationService.createPendingMember(oAuthUser);
signupTokenService.createToken(oAuthUser, nonce);
httpResponseService.redirectToSignup();
signupTokenService.createAndSetToken(oAuthUser, nonce);
httpResponseService.redirectToSignup(oAuthUser.email());

log.debug("New member flow initiated for: {}", oAuthUser.email());
}

private void handlePendingMember(Member member, OAuthUser oAuthUser) {
String nonce = memberCreationService.getExistingNonce(member);
signupTokenService.createToken(oAuthUser, nonce);
httpResponseService.redirectToSignup();
signupTokenService.createAndSetToken(oAuthUser, nonce);
httpResponseService.redirectToSignup(oAuthUser.email());

log.debug("Pending member flow initiated for: {}", oAuthUser.email());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ public OAuthUser authenticateWithProvider(Provider provider, String code) {
return providerFactory.getOAuthUser(provider, code, redirectUrl);

} catch (Exception e) {
throw new AuthException(ErrorCode.INVALID_AUTHENTICATION);
log.error("OAuth authentication failed", e);
throw new AuthException(ErrorCode.OAUTH_AUTHENTICATION_FAILED);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.juu.juulabel.auth.service;

import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
Expand Down Expand Up @@ -36,6 +37,7 @@ public class SignupTokenService extends PasetoTokenService {
private static final String PROVIDER_ID_CLAIM = "providerId";
private static final String NONCE_CLAIM = "nonce";
private static final String AUDIENCE_CLAIM_KEY = "aud";
private static final Duration SIGN_UP_TOKEN_TTL = Duration.ofMinutes(15);

private final SignupTokenValidator validator;
private final MemberReader memberReader;
Expand All @@ -47,7 +49,7 @@ public SignupTokenService(
MemberReader memberReader,
CookieService cookieService) {

super(secretKey, AuthConstants.SIGN_UP_TOKEN_DURATION);
super(secretKey, SIGN_UP_TOKEN_TTL);
this.validator = validator;
this.memberReader = memberReader;
this.cookieService = cookieService;
Expand All @@ -68,7 +70,7 @@ public void createAndSetToken(OAuthUser oAuthUser, String nonce) {
cookieService.addCookie(
AuthConstants.SIGN_UP_TOKEN_NAME,
token,
(int) AuthConstants.SIGN_UP_TOKEN_DURATION.toSeconds());
(int) SIGN_UP_TOKEN_TTL.toSeconds());
}

/**
Expand Down Expand Up @@ -108,15 +110,6 @@ public String resolveToken(String header) {
return header.replace(AuthConstants.TOKEN_PREFIX, "");
}

/**
* Creates signup token and sets it as cookie
* This method name maintains compatibility with the existing
* SignupTokenProvider
*/
public void createToken(OAuthUser oAuthUser, String nonce) {
createAndSetToken(oAuthUser, nonce);
}

/**
* Converts PASETO Claims to Map for easier processing
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public Authentication authenticate(HttpServletRequest request) {

if (signupToken == null || signupToken.trim().isEmpty()) {
log.warn("Signup token missing for signup request: {}", request.getRequestURI());
throw new AuthException(ErrorCode.SIGN_UP_SESSION_EXPIRED);
throw new AuthException(ErrorCode.SIGN_UP_TOKEN_NOT_FOUND);
}

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ public class SecurityConfig {
"http://localhost:8080",
"http://localhost:8084",
"http://localhost:5173",
"http://localhost:3000",
"http://localhost:3000",
"https://local.juulabel.com:3000",
"https://juulabel.com",
"https://api.juulabel.com",
"https://dev.juulabel.com",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.juu.juulabel.common.constants;

import java.time.Duration;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

Expand All @@ -12,8 +10,7 @@ public class AuthConstants {
public static final String AUTH_TOKEN_NAME = "auth_token";
public static final String SIGN_UP_TOKEN_NAME = "sign_up_token";

public static final int USER_SESSION_TTL = 60 * 60 * 24 * 7;
public static final Duration SIGN_UP_TOKEN_DURATION = Duration.ofMinutes(15);
public static final int USER_SESSION_TTL = 60 * 60 * 24 * 7; // 7 days

// Redis Prefix
public static final String USER_SESSION_PREFIX = "user_session";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,6 @@ public enum ErrorCode {
INVALID_AUTHENTICATION(HttpStatus.UNAUTHORIZED, "인증이 올바르지 않습니다."),
NOT_FOUND(HttpStatus.NOT_FOUND, "요청한 리소스를 찾을 수 없습니다."),

/**
* CSRF Security
*/
CSRF_TOKEN_INVALID(HttpStatus.FORBIDDEN, "CSRF 토큰이 유효하지 않습니다."),
CSRF_TOKEN_MISSING(HttpStatus.FORBIDDEN, "CSRF 토큰이 누락되었습니다."),
CSRF_TOKEN_MISMATCH(HttpStatus.FORBIDDEN, "CSRF 토큰이 일치하지 않습니다."),

/**
* Json Web Token
*/
Expand All @@ -53,8 +46,10 @@ public enum ErrorCode {
* Authorization
*/
DEVICE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "헤더에 Device-Id가 누락되었습니다."),
OAUTH_AUTHENTICATION_FAILED(HttpStatus.BAD_REQUEST, "소셜 로그인 인증에 실패하였습니다."),
OAUTH_PROVIDER_NOT_FOUND(HttpStatus.BAD_REQUEST, "소셜 로그인 경로를 찾을 수 없습니다."),

SIGN_UP_TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "회원가입 토큰이 누락되었습니다."),
SIGN_UP_SESSION_EXPIRED(HttpStatus.BAD_REQUEST, "회원가입 세션이 만료되었습니다."),

/**
Expand Down
Loading
Loading