diff --git a/AGENTS.md b/AGENTS.md index 78b6a81..c1bff7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,8 @@ | 영역 | 기술 | |------|------| | Frontend | Vue 3, Vite, Pinia, Axios, 네이버지도 SDK, Tailwind CSS | -| Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL | -| AI Backend | Python 3.11, FastAPI, LangGraph, Codex API (OpenAI) | +| Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL, Redis, Gmail SMTP | +| AI Backend | Python 3.11, FastAPI, LangGraph, Claude API (Anthropic) | | DB | Supabase (PostgreSQL + pgvector) | | Infra | Cloud Run (backend + backend-ai 각각 독립 배포) | | Batch | Spring Scheduler (국토교통부·생활안전지도 공공 API) | diff --git a/CLAUDE.md b/CLAUDE.md index 4b065e8..ab80cf3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ | 영역 | 기술 | |------|------| | Frontend | Vue 3, Vite, Pinia, Axios, 네이버지도 SDK, Tailwind CSS | -| Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL | +| Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL, Redis, Gmail SMTP | | AI Backend | Python 3.11, FastAPI, LangGraph, Claude API (Anthropic) | | DB | Supabase (PostgreSQL + pgvector) | | Infra | Cloud Run (backend + backend-ai 각각 독립 배포) | @@ -51,5 +51,11 @@ cd backend-ai && uvicorn app.main:app --reload # 개발 서버 cd backend-ai && pytest tests/ # 테스트 ``` +## 로컬 개발 사전 조건 + +- Redis 실행 필수: `redis-server` (brew) 또는 `docker run -p 6379:6379 redis` +- `backend/.env` 파일 필요 (`.gitignore`에 포함, 팀원에게 별도 공유) +- `frontend/.env` 파일 필요 (`.gitignore`에 포함, 팀원에게 별도 공유) + ## 문서 -`docs/` 폴더에 01~12 번호 순서로 정렬되어 있습니다. +`docs/` 폴더에 01~13 번호 순서로 정렬되어 있습니다. diff --git a/backend/.env.example b/backend/.env.example deleted file mode 100644 index 5ef0e9f..0000000 --- a/backend/.env.example +++ /dev/null @@ -1,10 +0,0 @@ -# Spring Boot datasource -# Use these values when running the backend locally. -# Example: -# SUPABASE_DB_URL=jdbc:postgresql://aws-0-ap-northeast-2.pooler.supabase.com:6543/postgres?sslmode=require -SUPABASE_DB_PASSWORD= -SUPABASE_DB_URL= -SUPABASE_DB_USERNAME= - -# Comma-separated frontend origins allowed to call /api/** from browsers. -APP_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 diff --git a/backend/pom.xml b/backend/pom.xml index 615b0cb..47be218 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -71,6 +71,25 @@ true + + + me.paulschwarz + spring-dotenv + 3.0.0 + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.springframework.boot + spring-boot-starter-mail + + org.springframework.boot diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java index 9ad4aea..020bf0e 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java +++ b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java @@ -5,9 +5,13 @@ public enum ErrorCode { INVALID_REQUEST(HttpStatus.BAD_REQUEST, "요청 파라미터가 올바르지 않습니다."), INVALID_BOUNDS(HttpStatus.BAD_REQUEST, "지도 범위 파라미터가 올바르지 않습니다."), + PROPERTY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 매물을 찾을 수 없습니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."), EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 가입된 이메일입니다."), - PROPERTY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 매물을 찾을 수 없습니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다."), + EMAIL_NOT_VERIFIED(HttpStatus.FORBIDDEN, "이메일 인증이 필요합니다."), + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "인증 코드가 올바르지 않습니다."), AI_SERVICE_UNAVAILABLE(HttpStatus.BAD_GATEWAY, "AI 서비스 응답이 없습니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); diff --git a/backend/src/main/java/com/ssafy/salmanhae/config/SecurityConfig.java b/backend/src/main/java/com/ssafy/salmanhae/config/SecurityConfig.java index 9fe6e45..bff7503 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/config/SecurityConfig.java +++ b/backend/src/main/java/com/ssafy/salmanhae/config/SecurityConfig.java @@ -37,8 +37,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth - // 인증 없이 허용 - .requestMatchers("/api/v1/auth/signup", "/api/v1/auth/login", "/api/v1/auth/refresh").permitAll() + // 인가 없이 허용 + .requestMatchers("/api/v1/auth/signup", "/api/v1/auth/login", "/api/v1/auth/refresh", + "/api/v1/auth/email/send", "/api/v1/auth/email/verify").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/properties/**", "/api/v1/map/**", "/api/v1/safety/**", "/api/v1/price-analysis").permitAll() // 나머지는 로그인 필요 .anyRequest().authenticated() diff --git a/backend/src/main/java/com/ssafy/salmanhae/controller/auth/AuthController.java b/backend/src/main/java/com/ssafy/salmanhae/controller/auth/AuthController.java index 0238aba..b99fd26 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/controller/auth/AuthController.java +++ b/backend/src/main/java/com/ssafy/salmanhae/controller/auth/AuthController.java @@ -1,12 +1,13 @@ package com.ssafy.salmanhae.controller.auth; -import com.ssafy.salmanhae.model.dto.auth.LoginRequest; -import com.ssafy.salmanhae.model.dto.auth.LoginResponse; -import com.ssafy.salmanhae.model.dto.auth.SignupRequest; +import com.ssafy.salmanhae.model.dto.auth.*; import com.ssafy.salmanhae.common.response.ApiResponse; import com.ssafy.salmanhae.service.auth.AuthService; +import com.ssafy.salmanhae.service.auth.EmailVerificationService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -18,6 +19,7 @@ public class AuthController { private final AuthService authService; + private final EmailVerificationService emailVerificationService; @PostMapping("/signup") public ResponseEntity> signup(@RequestBody SignupRequest request) { @@ -32,7 +34,26 @@ public ResponseEntity> login(@RequestBody LoginReques } @PostMapping("/logout") - public ResponseEntity> logout() { + public ResponseEntity> logout(@AuthenticationPrincipal User user) { + authService.logout(user.getEmail()); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PostMapping("/refresh") + public ResponseEntity> refresh(@Valid @RequestBody RefreshRequest request) { + RefreshResponse response = authService.refresh(request.getRefreshToken()); + return ResponseEntity.ok(ApiResponse.ok(response)); + } + + @PostMapping("/email/send") + public ResponseEntity> sendVerificationEmail(@Valid @RequestBody EmailSendRequest request) { + emailVerificationService.sendCode(request.getEmail()); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @PostMapping("/email/verify") + public ResponseEntity> verifyEmail(@Valid @RequestBody EmailVerifyRequest request) { + emailVerificationService.verifyCode(request.getEmail(), request.getCode()); return ResponseEntity.ok(ApiResponse.ok()); } } diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/EmailSendRequest.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/EmailSendRequest.java new file mode 100644 index 0000000..754858f --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/EmailSendRequest.java @@ -0,0 +1,12 @@ +package com.ssafy.salmanhae.model.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class EmailSendRequest { + @NotBlank + @Email + private String email; +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/EmailVerifyRequest.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/EmailVerifyRequest.java new file mode 100644 index 0000000..1e5be16 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/EmailVerifyRequest.java @@ -0,0 +1,17 @@ +package com.ssafy.salmanhae.model.dto.auth; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; + +@Getter +public class EmailVerifyRequest { + @NotBlank + @Email + private String email; + + @NotBlank + @Pattern(regexp = "^\\d{6}$") + private String code; +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/RefreshRequest.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/RefreshRequest.java new file mode 100644 index 0000000..7d0e474 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/RefreshRequest.java @@ -0,0 +1,10 @@ +package com.ssafy.salmanhae.model.dto.auth; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class RefreshRequest { + @NotBlank + private String refreshToken; +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/RefreshResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/RefreshResponse.java new file mode 100644 index 0000000..0462d78 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/auth/RefreshResponse.java @@ -0,0 +1,11 @@ +package com.ssafy.salmanhae.model.dto.auth; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class RefreshResponse { + private String accessToken; + private String refreshToken; +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/auth/AuthService.java b/backend/src/main/java/com/ssafy/salmanhae/service/auth/AuthService.java index 6bc82cc..154b025 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/service/auth/AuthService.java +++ b/backend/src/main/java/com/ssafy/salmanhae/service/auth/AuthService.java @@ -4,12 +4,19 @@ import com.ssafy.salmanhae.common.exception.ErrorCode; import com.ssafy.salmanhae.model.dao.auth.UserDao; import com.ssafy.salmanhae.model.dto.auth.LoginResponse; +import com.ssafy.salmanhae.model.dto.auth.RefreshResponse; import com.ssafy.salmanhae.model.dto.auth.User; import com.ssafy.salmanhae.util.JwtUtil; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.time.Duration; + @Service @RequiredArgsConstructor public class AuthService { @@ -17,8 +24,18 @@ public class AuthService { private final UserDao userDao; private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; + private final StringRedisTemplate redisTemplate; + + @Value("${jwt.refresh-token-expiration}") + private long refreshTokenExpiration; + + private static final String REFRESH_PREFIX = "refresh:"; public void signup(String email, String password, String nickname) { + if (!Boolean.TRUE.toString().equals(redisTemplate.opsForValue().get("email:verified:" + email))) { + throw new ApiException(ErrorCode.EMAIL_NOT_VERIFIED); + } + User user = User.builder() .email(email) .password(passwordEncoder.encode(password)) @@ -33,9 +50,36 @@ public LoginResponse login(String email, String password) { throw new ApiException(ErrorCode.UNAUTHORIZED); } - return new LoginResponse( - jwtUtil.generateAccessToken(email), - jwtUtil.generateRefreshToken(email) - ); + String accessToken = jwtUtil.generateAccessToken(email); + String refreshToken = jwtUtil.generateRefreshToken(email); + redisTemplate.opsForValue().set(REFRESH_PREFIX + email, refreshToken, Duration.ofMillis(refreshTokenExpiration)); + + return new LoginResponse(accessToken, refreshToken); + } + + public RefreshResponse refresh(String refreshToken) { + String email; + try { + email = jwtUtil.getEmail(refreshToken); + } catch (ExpiredJwtException e) { + throw new ApiException(ErrorCode.EXPIRED_TOKEN); + } catch (JwtException | IllegalArgumentException e) { + throw new ApiException(ErrorCode.INVALID_TOKEN); + } + + String stored = redisTemplate.opsForValue().get(REFRESH_PREFIX + email); + if (!refreshToken.equals(stored)) { + throw new ApiException(ErrorCode.INVALID_TOKEN); + } + + String newAccessToken = jwtUtil.generateAccessToken(email); + String newRefreshToken = jwtUtil.generateRefreshToken(email); + redisTemplate.opsForValue().set(REFRESH_PREFIX + email, newRefreshToken, Duration.ofMillis(refreshTokenExpiration)); + + return new RefreshResponse(newAccessToken, newRefreshToken); + } + + public void logout(String email) { + redisTemplate.delete(REFRESH_PREFIX + email); } -} +} \ No newline at end of file diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/auth/EmailVerificationService.java b/backend/src/main/java/com/ssafy/salmanhae/service/auth/EmailVerificationService.java new file mode 100644 index 0000000..fb1227a --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/service/auth/EmailVerificationService.java @@ -0,0 +1,50 @@ +package com.ssafy.salmanhae.service.auth; + +import com.ssafy.salmanhae.common.exception.ApiException; +import com.ssafy.salmanhae.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class EmailVerificationService { + + private final StringRedisTemplate redisTemplate; + private final JavaMailSender mailSender; + + @Value("${spring.mail.username}") + private String senderEmail; + + private static final String VERIFY_PREFIX = "email:verify:"; + private static final String VERIFIED_PREFIX = "email:verified:"; + + public void sendCode(String email) { + String code = String.format("%06d", new SecureRandom().nextInt(1_000_000)); + redisTemplate.opsForValue().set(VERIFY_PREFIX + email, code, Duration.ofMinutes(5)); + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(senderEmail); + message.setTo(email); + message.setSubject("[살만해] 이메일 인증 코드"); + message.setText("인증 코드: " + code + "\n5분 내에 입력해주세요."); + mailSender.send(message); + } + + public void verifyCode(String email, String code) { + String saved = redisTemplate.opsForValue().get(VERIFY_PREFIX + email); + + if (saved == null || !saved.equals(code)) { + throw new ApiException(ErrorCode.INVALID_VERIFICATION_CODE); + } + + redisTemplate.delete(VERIFY_PREFIX + email); + redisTemplate.opsForValue().set(VERIFIED_PREFIX + email, "true", Duration.ofMinutes(10)); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 2c4be5e..9e94ce0 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,8 +1,11 @@ spring.application.name=salmanhae -spring.profiles.active=local # DB +# Required: SUPABASE_DB_URL, SUPABASE_DB_USERNAME, SUPABASE_DB_PASSWORD spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.url=${SUPABASE_DB_URL} +spring.datasource.username=${SUPABASE_DB_USERNAME} +spring.datasource.password=${SUPABASE_DB_PASSWORD} # MyBatis mybatis.mapper-locations=classpath:mappers/**/*.xml @@ -10,16 +13,24 @@ mybatis.type-aliases-package=com.ssafy.salmanhae.model.dto mybatis.configuration.map-underscore-to-camel-case=true # JWT +jwt.secret=${JWT_SECRET} jwt.access-token-expiration=900000 jwt.refresh-token-expiration=604800000 -# Required environment variables: -# SUPABASE_DB_URL - PostgreSQL JDBC connection string -# SUPABASE_DB_USERNAME - database username -# SUPABASE_DB_PASSWORD - database password -spring.datasource.url=${SUPABASE_DB_URL:} -spring.datasource.username=${SUPABASE_DB_USERNAME:} -spring.datasource.password=${SUPABASE_DB_PASSWORD:} -spring.datasource.driver-class-name=org.postgresql.Driver + +# CORS +app.cors.allowed-origins=${APP_CORS_ALLOWED_ORIGINS:http://localhost:5173} + +# Redis +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} + +# Mail +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=${MAIL_USERNAME} +spring.mail.password=${MAIL_PASSWORD} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true # Backend AI internal API ai.agent.base-url=${AI_AGENT_BASE_URL:http://localhost:8000} diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthControllerTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthControllerTest.java index 2ef1cf3..f1e49d8 100644 --- a/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthControllerTest.java +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthControllerTest.java @@ -1,17 +1,26 @@ package com.ssafy.salmanhae.controller.auth; import static org.hamcrest.Matchers.nullValue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import org.springframework.http.MediaType; +import org.springframework.mail.javamail.JavaMailSender; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; import org.springframework.transaction.annotation.Transactional; @SpringBootTest @@ -23,12 +32,36 @@ class AuthControllerTest { @Autowired private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private StringRedisTemplate redisTemplate; + + @MockBean + private JavaMailSender mailSender; + + private ValueOperations valueOps; + private static final String EMAIL = "test@example.com"; private static final String PASSWORD = "password123"; private static final String NICKNAME = "테스터"; + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + valueOps = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + } + + private void setEmailVerified(String email) { + when(valueOps.get("email:verified:" + email)).thenReturn("true"); + } + @Test void signupReturnsOk() throws Exception { + setEmailVerified(EMAIL); + mockMvc.perform(post("/api/v1/auth/signup") .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -39,8 +72,20 @@ void signupReturnsOk() throws Exception { .andExpect(jsonPath("$.message").value("OK")); } + @Test + void signupFailsWhenEmailNotVerified() throws Exception { + mockMvc.perform(post("/api/v1/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email": "%s", "password": "%s", "nickname": "%s"} + """.formatted(EMAIL, PASSWORD, NICKNAME))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("EMAIL_NOT_VERIFIED")); + } + @Test void loginReturnsAccessTokenAndRefreshToken() throws Exception { + setEmailVerified(EMAIL); signup(EMAIL, PASSWORD, NICKNAME); mockMvc.perform(post("/api/v1/auth/login") @@ -65,6 +110,45 @@ void loginRejectsInvalidCredentials() throws Exception { .andExpect(jsonPath("$.code").value("UNAUTHORIZED")); } + @Test + void refreshReturnsNewTokens() throws Exception { + setEmailVerified(EMAIL); + signup(EMAIL, PASSWORD, NICKNAME); + + MvcResult loginResult = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email": "%s", "password": "%s"} + """.formatted(EMAIL, PASSWORD))) + .andExpect(status().isOk()) + .andReturn(); + + String refreshToken = objectMapper.readTree(loginResult.getResponse().getContentAsString()) + .path("data").path("refreshToken").asText(); + + when(valueOps.get("refresh:" + EMAIL)).thenReturn(refreshToken); + + mockMvc.perform(post("/api/v1/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"refreshToken": "%s"} + """.formatted(refreshToken))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()); + } + + @Test + void refreshFailsWithInvalidToken() throws Exception { + mockMvc.perform(post("/api/v1/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"refreshToken": "invalid.token.value"} + """)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value("INVALID_TOKEN")); + } + private void signup(String email, String password, String nickname) throws Exception { mockMvc.perform(post("/api/v1/auth/signup") .contentType(MediaType.APPLICATION_JSON) diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthHttpIntegrationTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthHttpIntegrationTest.java index 7a19df8..9ede5ed 100644 --- a/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthHttpIntegrationTest.java +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/auth/AuthHttpIntegrationTest.java @@ -1,18 +1,25 @@ package com.ssafy.salmanhae.controller.auth; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.util.Map; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.mail.javamail.JavaMailSender; import org.springframework.test.context.ActiveProfiles; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -22,8 +29,25 @@ class AuthHttpIntegrationTest { @Autowired private TestRestTemplate restTemplate; + @MockBean + private StringRedisTemplate redisTemplate; + + @MockBean + private JavaMailSender mailSender; + + private ValueOperations valueOps; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + valueOps = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOps); + } + @Test void signupLoginFlow() { + when(valueOps.get("email:verified:flow@example.com")).thenReturn("true"); + HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); diff --git a/backend/src/test/java/com/ssafy/salmanhae/service/auth/AuthServiceTest.java b/backend/src/test/java/com/ssafy/salmanhae/service/auth/AuthServiceTest.java new file mode 100644 index 0000000..aa44325 --- /dev/null +++ b/backend/src/test/java/com/ssafy/salmanhae/service/auth/AuthServiceTest.java @@ -0,0 +1,109 @@ +package com.ssafy.salmanhae.service.auth; + +import com.ssafy.salmanhae.common.exception.ApiException; +import com.ssafy.salmanhae.common.exception.ErrorCode; +import com.ssafy.salmanhae.model.dao.auth.UserDao; +import com.ssafy.salmanhae.model.dto.auth.RefreshResponse; +import com.ssafy.salmanhae.model.dto.auth.User; +import com.ssafy.salmanhae.util.JwtUtil; +import io.jsonwebtoken.ExpiredJwtException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import org.mockito.quality.Strictness; +import org.mockito.junit.jupiter.MockitoSettings; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class AuthServiceTest { + + @Mock private UserDao userDao; + @Mock private PasswordEncoder passwordEncoder; + @Mock private JwtUtil jwtUtil; + @Mock private StringRedisTemplate redisTemplate; + @Mock private ValueOperations valueOps; + + @InjectMocks + private AuthService authService; + + private static final String EMAIL = "test@example.com"; + private static final String REFRESH_TOKEN = "refresh-token"; + private static final String NEW_ACCESS_TOKEN = "new-access-token"; + private static final String NEW_REFRESH_TOKEN = "new-refresh-token"; + private static final long REFRESH_EXPIRATION = 604800000L; + + @BeforeEach + void setUp() { + when(redisTemplate.opsForValue()).thenReturn(valueOps); + ReflectionTestUtils.setField(authService, "refreshTokenExpiration", REFRESH_EXPIRATION); + } + + @Test + void login_savesRefreshTokenToRedis() { + User user = User.builder().email(EMAIL).password("encoded").nickname("테스터").build(); + when(userDao.findByEmail(EMAIL)).thenReturn(user); + when(passwordEncoder.matches("password", "encoded")).thenReturn(true); + when(jwtUtil.generateAccessToken(EMAIL)).thenReturn("access-token"); + when(jwtUtil.generateRefreshToken(EMAIL)).thenReturn(REFRESH_TOKEN); + + authService.login(EMAIL, "password"); + + verify(valueOps).set(eq("refresh:" + EMAIL), eq(REFRESH_TOKEN), eq(Duration.ofMillis(REFRESH_EXPIRATION))); + } + + @Test + void refresh_validToken_rotatesTokens() { + when(jwtUtil.getEmail(REFRESH_TOKEN)).thenReturn(EMAIL); + when(valueOps.get("refresh:" + EMAIL)).thenReturn(REFRESH_TOKEN); + when(jwtUtil.generateAccessToken(EMAIL)).thenReturn(NEW_ACCESS_TOKEN); + when(jwtUtil.generateRefreshToken(EMAIL)).thenReturn(NEW_REFRESH_TOKEN); + + RefreshResponse response = authService.refresh(REFRESH_TOKEN); + + assertThat(response.getAccessToken()).isEqualTo(NEW_ACCESS_TOKEN); + assertThat(response.getRefreshToken()).isEqualTo(NEW_REFRESH_TOKEN); + verify(valueOps).set(eq("refresh:" + EMAIL), eq(NEW_REFRESH_TOKEN), eq(Duration.ofMillis(REFRESH_EXPIRATION))); + } + + @Test + void refresh_tokenNotInRedis_throwsException() { + when(jwtUtil.getEmail(REFRESH_TOKEN)).thenReturn(EMAIL); + when(valueOps.get("refresh:" + EMAIL)).thenReturn(null); + + assertThatThrownBy(() -> authService.refresh(REFRESH_TOKEN)) + .isInstanceOf(ApiException.class) + .satisfies(e -> assertThat(((ApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_TOKEN)); + } + + @Test + void refresh_expiredToken_throwsException() { + when(jwtUtil.getEmail(REFRESH_TOKEN)).thenThrow(ExpiredJwtException.class); + + assertThatThrownBy(() -> authService.refresh(REFRESH_TOKEN)) + .isInstanceOf(ApiException.class) + .satisfies(e -> assertThat(((ApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.EXPIRED_TOKEN)); + } + + @Test + void logout_deletesRefreshTokenFromRedis() { + authService.logout(EMAIL); + + verify(redisTemplate).delete("refresh:" + EMAIL); + } +} diff --git a/backend/src/test/java/com/ssafy/salmanhae/service/auth/EmailVerificationServiceTest.java b/backend/src/test/java/com/ssafy/salmanhae/service/auth/EmailVerificationServiceTest.java new file mode 100644 index 0000000..b4f034c --- /dev/null +++ b/backend/src/test/java/com/ssafy/salmanhae/service/auth/EmailVerificationServiceTest.java @@ -0,0 +1,84 @@ +package com.ssafy.salmanhae.service.auth; + +import com.ssafy.salmanhae.common.exception.ApiException; +import com.ssafy.salmanhae.common.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class EmailVerificationServiceTest { + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private JavaMailSender mailSender; + + @Mock + private ValueOperations valueOps; + + @InjectMocks + private EmailVerificationService emailVerificationService; + + private static final String EMAIL = "test@example.com"; + + @BeforeEach + void setUp() { + when(redisTemplate.opsForValue()).thenReturn(valueOps); + ReflectionTestUtils.setField(emailVerificationService, "senderEmail", "sender@gmail.com"); + } + + @Test + void sendCode_savesCodeToRedisAndSendsEmail() { + emailVerificationService.sendCode(EMAIL); + + verify(valueOps).set(eq("email:verify:" + EMAIL), anyString(), eq(Duration.ofMinutes(5))); + verify(mailSender).send(any(SimpleMailMessage.class)); + } + + @Test + void verifyCode_validCode_savesVerifiedKey() { + when(valueOps.get("email:verify:" + EMAIL)).thenReturn("123456"); + + emailVerificationService.verifyCode(EMAIL, "123456"); + + verify(redisTemplate).delete("email:verify:" + EMAIL); + verify(valueOps).set(eq("email:verified:" + EMAIL), eq("true"), eq(Duration.ofMinutes(10))); + } + + @Test + void verifyCode_invalidCode_throwsException() { + when(valueOps.get("email:verify:" + EMAIL)).thenReturn("123456"); + + assertThatThrownBy(() -> emailVerificationService.verifyCode(EMAIL, "999999")) + .isInstanceOf(ApiException.class) + .satisfies(e -> assertThat(((ApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_VERIFICATION_CODE)); + } + + @Test + void verifyCode_codeNotFound_throwsException() { + when(valueOps.get("email:verify:" + EMAIL)).thenReturn(null); + + assertThatThrownBy(() -> emailVerificationService.verifyCode(EMAIL, "123456")) + .isInstanceOf(ApiException.class) + .satisfies(e -> assertThat(((ApiException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_VERIFICATION_CODE)); + } +} diff --git a/backend/src/test/resources/application-test.properties b/backend/src/test/resources/application-test.properties index 56eaae2..3192124 100644 --- a/backend/src/test/resources/application-test.properties +++ b/backend/src/test/resources/application-test.properties @@ -6,6 +6,10 @@ spring.sql.init.mode=always jwt.secret=salmanhae-test-secret-key-32bytes!! jwt.access-token-expiration=900000 jwt.refresh-token-expiration=604800000 +spring.mail.host=localhost +spring.mail.port=25 +spring.mail.username=test@test.com +spring.mail.password=test ai.agent.base-url=http://localhost:8000 ai.agent.internal-api-key=change-me ai.agent.connect-timeout-ms=2000 diff --git a/docs/02_ARCHITECTURE.md b/docs/02_ARCHITECTURE.md index cac70af..5397d5d 100644 --- a/docs/02_ARCHITECTURE.md +++ b/docs/02_ARCHITECTURE.md @@ -46,10 +46,10 @@ salmanhae/ │ │ │ ├── auth/ # UserDao (MyBatis @Mapper) │ │ │ └── property/ # PropertyDao │ │ └── dto/ -│ │ ├── auth/ # User (implements UserDetails), LoginRequest, LoginResponse, SignupRequest +│ │ ├── auth/ # User (implements UserDetails), LoginRequest, LoginResponse, SignupRequest, RefreshRequest, RefreshResponse, EmailSendRequest, EmailVerifyRequest │ │ └── property/ # PropertyRow, PropertySummaryResponse, PropertyDetailResponse 등 │ ├── service/ -│ │ ├── auth/ # AuthService, CustomUserDetailsService +│ │ ├── auth/ # AuthService, CustomUserDetailsService, EmailVerificationService │ │ └── property/ # PropertyService, PropertyServiceImpl │ ├── util/ # JwtUtil │ └── batch/ # Spring Scheduler 배치 작업 (예정) @@ -72,8 +72,9 @@ salmanhae/ ### Spring Boot (Cloud Run) -- 회원가입/로그인/로그아웃 API (Spring Security 자체 구현) -- JWT 발급 및 검증 (Spring Security Filter) +- 회원가입/로그인/로그아웃/토큰 갱신 API (Spring Security 자체 구현) +- 회원가입 전 이메일 인증 (Gmail SMTP + Redis TTL 5분) +- JWT 발급 및 검증 (Spring Security Filter), 리프레시 토큰 Redis 저장 및 Token Rotation - 공공데이터 배치 수집 → PostgreSQL 저장 - F-1 MVP 샘플 매물 및 운영 매물 데이터 저장/조회 API 제공 - 실거래가 건물 anchor 지오코딩 결과를 DB에 저장하고 지도 API에서는 저장 좌표만 조회 @@ -91,6 +92,13 @@ salmanhae/ - `analyze_safety` → Spring Boot API 호출 - Claude API로 최종 자연어 응답 생성 +### Redis + +- 이메일 인증 코드 (`email:verify:{email}`, TTL 5분) +- 이메일 인증 완료 플래그 (`email:verified:{email}`, TTL 10분) +- 리프레시 토큰 (`refresh:{email}`, TTL 7일) +- 로컬 개발: `localhost:6379` / 운영: 환경변수 `REDIS_HOST`, `REDIS_PORT` + ### Supabase - DB (PostgreSQL): 매물, 실거래가, 안전시설, 찜하기, 대화 기록, 사용자 diff --git a/docs/03_ADR.md b/docs/03_ADR.md index be0f007..ff448ec 100644 --- a/docs/03_ADR.md +++ b/docs/03_ADR.md @@ -23,7 +23,7 @@ ### ADR-005: 인증을 Spring Security + JWT로 자체 구현 **결정**: 회원가입·로그인은 Spring Security가 직접 처리하고, JWT로 액세스 토큰·리프레시 토큰을 발급한다. 이후 모든 요청에서 Spring Security JWT 필터가 토큰을 검증한다. **이유**: 외부 Auth 서비스 의존성 없이 토큰 정책(만료 시간, 클레임 구조)을 프로젝트 내에서 완전히 제어 가능하고 장애 지점이 줄어든다. -**트레이드오프**: 비밀번호 해싱(BCrypt), 토큰 발급·검증 로직을 직접 관리해야 한다. 리프레시 토큰 무효화는 DB 저장 또는 별도 블랙리스트로 처리한다. +**트레이드오프**: 비밀번호 해싱(BCrypt), 토큰 발급·검증 로직을 직접 관리해야 한다. 리프레시 토큰은 Redis에 저장하며 Token Rotation 방식으로 관리한다 (ADR-011 참고). ### ADR-006: LangGraph 의도 분류를 단일 노드에서 처리 **결정**: 사용자 입력을 4가지 의도(매물 추천/법률 상담/시세 분석/안전 분석)로 분류하는 노드를 LangGraph 그래프 진입점에 배치하고, 의도에 따라 엣지가 분기된다. @@ -45,6 +45,11 @@ **이유**: WMS는 프론트 지도 렌더링, 레이어 정합성, 범례 해석, 성능 검증 범위가 커서 MVP 일정 리스크가 높다. **트레이드오프**: 범죄주의구간 같은 면 단위 위험 정보는 초기 버전에서 제공하지 못한다. +### ADR-011: 이메일 인증 코드와 리프레시 토큰을 Redis로 관리한다 +**결정**: 이메일 인증 코드(`email:verify:{email}`, TTL 5분), 인증 완료 플래그(`email:verified:{email}`, TTL 10분), 리프레시 토큰(`refresh:{email}`, TTL 7일)을 모두 Redis에 저장한다. 리프레시 토큰 갱신 시 Token Rotation(새 액세스 + 새 리프레시 동시 발급)을 적용한다. +**이유**: 이메일 인증 코드는 단기 TTL과 자동 삭제가 핵심이라 RDB보다 Redis가 적합하다. 리프레시 토큰을 Redis에 저장하면 로그아웃 시 즉시 무효화가 가능하고, Token Rotation으로 탈취된 토큰 재사용을 탐지할 수 있다. +**트레이드오프**: Redis가 로컬 인프라에 추가된다. Redis 장애 시 로그인·회원가입 불가. 로컬 개발 환경에서 Redis 실행이 필수(`redis-server` 또는 Docker). + ### ADR-010: 지도 줌 레벨별 표시 데이터를 서버에서 결정한다 **결정**: 프론트는 네이버지도 bounds와 zoom을 Spring Boot에 전달하고, 서버는 시/도·시/군/구·읍/면/동 평균 또는 매물/클러스터 데이터를 선택해 반환한다. **이유**: 지도 표시 정책과 집계 기준을 백엔드에서 일관 관리하면 프론트 구현이 단순해지고, 실거래가 평균 계산을 DB 캐시와 함께 최적화할 수 있다. diff --git a/docs/08_API_SPEC.md b/docs/08_API_SPEC.md index 6728f14..d86ad6d 100644 --- a/docs/08_API_SPEC.md +++ b/docs/08_API_SPEC.md @@ -52,8 +52,12 @@ | --- | --- | --- | | 400 | `INVALID_REQUEST` | 요청 파라미터 누락 또는 형식 오류 | | 400 | `INVALID_BOUNDS` | 지도 범위 파라미터 오류 (west/east/south/north) | -| 401 | `UNAUTHORIZED` | 인증 토큰 없음 또는 만료 | +| 400 | `INVALID_VERIFICATION_CODE` | 이메일 인증 코드가 올바르지 않음 | +| 401 | `UNAUTHORIZED` | 인증 토큰 없음 또는 자격증명 불일치 | +| 401 | `INVALID_TOKEN` | 서명 검증 실패 또는 Redis 불일치 리프레시 토큰 | +| 401 | `EXPIRED_TOKEN` | 만료된 리프레시 토큰 | | 403 | `FORBIDDEN` | 권한 없음 (다른 사용자 리소스 접근 등) | +| 403 | `EMAIL_NOT_VERIFIED` | 이메일 인증 미완료 상태에서 회원가입 시도 | | 404 | `PROPERTY_NOT_FOUND` | 매물 없음 | | 404 | `WISHLIST_NOT_FOUND` | 찜 항목 없음 | | 404 | `SESSION_NOT_FOUND` | 대화 세션 없음 | @@ -407,6 +411,33 @@ Authorization: Bearer {token} Spring Boot가 Spring Security + JJWT로 자체 구현한 인증 API입니다. 프론트엔드는 이 API만 호출하며, 외부 Auth 서비스를 직접 호출하지 않습니다. +회원가입 전 이메일 인증이 필수입니다. 인증 코드는 Gmail SMTP로 발송되고 Redis에 5분간 보관됩니다. + +### 이메일 인증 코드 발송 +```http +POST /api/v1/auth/email/send +``` +```json +{ "email": "user@example.com" } +``` +**Response** +```json +{ "data": null, "message": "OK" } +``` + +### 이메일 인증 코드 검증 +```http +POST /api/v1/auth/email/verify +``` +```json +{ "email": "user@example.com", "code": "123456" } +``` +**Response** +```json +{ "data": null, "message": "OK" } +``` +**Error** — 코드 불일치 또는 만료 시: `400 INVALID_VERIFICATION_CODE` + ### 회원가입 ```http POST /api/v1/auth/signup @@ -414,13 +445,13 @@ POST /api/v1/auth/signup ```json { "email": "user@example.com", "password": "password123", "nickname": "홍길동" } ``` +이메일 인증(`/email/verify`) 완료 후 10분 이내에 호출해야 합니다. + **Response** ```json -{ - "data": null, - "message": "OK" -} +{ "data": null, "message": "OK" } ``` +**Error** — 인증 미완료 시: `403 EMAIL_NOT_VERIFIED` ### 로그인 ```http @@ -440,28 +471,38 @@ POST /api/v1/auth/login } ``` -### 토큰 갱신 +### 토큰 갱신 (Token Rotation) ```http POST /api/v1/auth/refresh ``` ```json { "refreshToken": "jwt-refresh-token" } ``` +갱신 시 액세스 토큰과 리프레시 토큰을 **모두 새로 발급**합니다 (Token Rotation). 프론트는 두 토큰을 모두 교체해야 합니다. + **Response** ```json { "data": { - "accessToken": "new-jwt-access-token" + "accessToken": "new-jwt-access-token", + "refreshToken": "new-jwt-refresh-token" }, "message": "OK" } ``` +**Error** — 서명 불일치·Redis 불일치: `401 INVALID_TOKEN` / 만료: `401 EXPIRED_TOKEN` ### 로그아웃 ```http POST /api/v1/auth/logout Authorization: Bearer {token} ``` +Redis에서 리프레시 토큰을 삭제하여 이후 갱신을 차단합니다. + +**Response** +```json +{ "data": null, "message": "OK" } +``` --- diff --git a/docs/10_SECURITY_POLICY.md b/docs/10_SECURITY_POLICY.md index 44b2b89..2db5e4d 100644 --- a/docs/10_SECURITY_POLICY.md +++ b/docs/10_SECURITY_POLICY.md @@ -10,20 +10,73 @@ F-1(지도 탐색)과 지도 기반 시세·안전 분석 조회는 비로그인 ## 인증 흐름 +### 회원가입 (이메일 인증 포함) + +``` +① POST /api/v1/auth/email/send (email) + → EmailVerificationService가 6자리 코드 생성 (SecureRandom) + → Redis에 email:verify:{email} = code 저장 (TTL 5분) + → Gmail SMTP로 인증 코드 발송 + +② POST /api/v1/auth/email/verify (email + code) + → Redis에서 email:verify:{email} 조회 후 비교 + → 일치하면 email:verified:{email} = "true" 저장 (TTL 10분) + → email:verify:{email} 키 삭제 + +③ POST /api/v1/auth/signup (email + password + nickname) + → email:verified:{email} 키 확인 → 없으면 403 EMAIL_NOT_VERIFIED + → BCrypt로 비밀번호 해시 후 DB 저장 +``` + +### 로그인 및 토큰 발급 + ``` -사용자 로그인 요청 -→ POST /api/v1/auth/login (email + password) -→ Spring Security AuthenticationManager로 자격증명 검증 +POST /api/v1/auth/login (email + password) → BCrypt로 저장된 password_hash 비교 → JWT로 액세스 토큰(15분) + 리프레시 토큰(7일) 발급 +→ Redis에 refresh:{email} = refreshToken 저장 (TTL 7일) → 프론트 Axios Interceptor에 저장 -→ API 요청 시 Authorization: Bearer {accessToken} 헤더 포함 +``` + +### 인증된 요청 + +``` +API 요청 시 Authorization: Bearer {accessToken} 헤더 포함 → Spring Security JwtAuthenticationFilter에서 토큰 서명·만료 검증 → CustomUserDetailsService.loadUserByUsername(email)로 User 객체 조회 → UsernamePasswordAuthenticationToken으로 감싸 SecurityContext에 저장 → 컨트롤러에서 @AuthenticationPrincipal User로 사용 ``` +### 토큰 갱신 (Token Rotation) + +``` +POST /api/v1/auth/refresh (refreshToken) +→ JWT 서명·만료 검증 +→ Redis refresh:{email} 값과 일치 여부 확인 (불일치 시 401) +→ 새 액세스 토큰 + 새 리프레시 토큰 발급 (rotation) +→ Redis refresh:{email} 갱신 +→ 두 토큰 모두 응답 +``` + +### 로그아웃 + +``` +POST /api/v1/auth/logout +→ SecurityContext에서 email 추출 +→ Redis refresh:{email} 삭제 → 이후 해당 리프레시 토큰으로 갱신 불가 +``` + +--- + +## Redis 키 구조 + +| 키 | 값 | TTL | 용도 | +| --- | --- | --- | --- | +| `email:verify:{email}` | 6자리 코드 | 5분 | 이메일 인증 코드 | +| `email:verified:{email}` | `"true"` | 10분 | 인증 완료 상태 (회원가입 허용 플래그) | +| `refresh:{email}` | JWT 리프레시 토큰 | 7일 | 리프레시 토큰 저장 (rotation·무효화) | + --- ## 토큰 정책 @@ -33,8 +86,9 @@ F-1(지도 탐색)과 지도 기반 시세·안전 분석 조회는 비로그인 | 액세스 토큰 만료 | 15분 | | 리프레시 토큰 만료 | 7일 | | 서명 알고리즘 | HS256 (서버 시크릿 키) | -| JWT 시크릿 키 | Spring 프로퍼티 `jwt.secret`으로 주입. 로컬: `application-local.properties`, 운영: 환경변수 `JWT_SECRET` (Spring relaxed binding으로 자동 매핑). 코드에 하드코딩 금지 | -| 리프레시 토큰 저장 | MVP에서는 발급만 하고 DB에 저장하지 않음. 1.5차에서 `refresh_token` 테이블 추가 예정 | +| JWT 시크릿 키 | Spring 프로퍼티 `jwt.secret`으로 주입. 로컬: `backend/.env`의 `JWT_SECRET`, 운영: 환경변수 `JWT_SECRET`. 코드에 하드코딩 금지 | +| 리프레시 토큰 저장 | Redis (`refresh:{email}`, TTL 7일). 갱신 시 rotate 발급. 로그아웃 시 삭제로 무효화. | +| 이메일 인증 | 회원가입 전 필수. Gmail SMTP + Redis로 6자리 코드 검증 (TTL 5분). | --- @@ -49,9 +103,11 @@ F-1(지도 탐색)과 지도 기반 시세·안전 분석 조회는 비로그인 | `GET /api/v1/properties/{id}/safety-summary` | 안전 요약은 공개 데이터 기반 | | `GET /api/v1/safety/facilities` | 안전시설은 공개 데이터 기반 | | `GET /api/v1/price-analysis` | 지도·매물 상세에 포함되는 시세 분석 | -| `POST /api/v1/auth/signup` | 회원가입 | +| `POST /api/v1/auth/email/send` | 이메일 인증 코드 발송 | +| `POST /api/v1/auth/email/verify` | 이메일 인증 코드 검증 | +| `POST /api/v1/auth/signup` | 회원가입 (이메일 인증 필수) | | `POST /api/v1/auth/login` | 로그인 | -| `POST /api/v1/auth/refresh` | 토큰 갱신 (1.5차 구현 예정) | +| `POST /api/v1/auth/refresh` | 토큰 갱신 (Token Rotation) | --- @@ -60,7 +116,7 @@ F-1(지도 탐색)과 지도 기반 시세·안전 분석 조회는 비로그인 | API | 이유 | | --- | --- | | `POST /api/v1/chat` | AI 에이전트 (F-2~F-5) | -| `POST /api/v1/auth/logout` | 로그아웃 (리프레시 토큰 무효화) | +| `POST /api/v1/auth/logout` | 로그아웃 (Redis 리프레시 토큰 무효화) | | `GET /api/v1/wishlist` | 찜 목록 조회 | | `POST /api/v1/wishlist` | 찜하기 | | `DELETE /api/v1/wishlist/{id}` | 찜 삭제 | @@ -73,7 +129,8 @@ F-1(지도 탐색)과 지도 기반 시세·안전 분석 조회는 비로그인 - 백엔드는 클라이언트가 보낸 userId를 직접 신뢰하지 않습니다. - 사용자 식별은 반드시 JWT 서명 검증 후 SecurityContext에서 가져옵니다. -- 리프레시 토큰은 MVP에서 발급만 하며, 1.5차에서 DB 저장 및 로그아웃 시 무효화를 구현합니다. +- 리프레시 토큰은 Redis에 저장하며, 갱신 시 rotate 발급하고 로그아웃 시 삭제합니다. +- 이메일 인증 코드와 리프레시 토큰은 Redis TTL로 자동 만료합니다. - 국토부, 생활안전지도, 재난안전, 크롤링 관련 키와 시크릿은 절대 프론트에 노출하지 않습니다. - 네이버지도 SDK처럼 브라우저에서 직접 쓰는 공개 클라이언트 키는 도메인 제한을 걸고, 서버용 시크릿과 분리합니다. - 비밀번호는 BCrypt로 해시하여 DB에 저장합니다. 평문 저장 금지. diff --git a/frontend/.env.example b/frontend/.env.example deleted file mode 100644 index 3d08556..0000000 --- a/frontend/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Browser-safe Naver Maps SDK key. Do not put the secret here. -VITE_NAVER_MAP_CLIENT_ID= - -# Spring Boot API base URL. Keep this pointing at the backend when verifying map markers. -VITE_API_BASE_URL=http://localhost:8080