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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies {
implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
implementation 'io.github.cdimascio:java-dotenv:5.2.2'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j:8.0.33'
Expand Down
15 changes: 10 additions & 5 deletions src/main/java/com/chaineeproject/chainee/auth/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,14 @@ public AuthTokens issueFor(User u) {
long accessTtl = jwtProps.accessTtl().toSeconds();
long refreshTtl = jwtProps.refreshTtl().toSeconds();

// ✅ DID 문자열 존재 여부가 아니라 didVerified 플래그 사용
String access = jwtService.createAccessToken(
u.getId(), u.getEmail(),
u.getId(),
u.getEmail(),
u.isKycVerified(),
u.getDid()!=null && !u.getDid().isBlank(),
accessTtl);
u.isDidVerified(), // <-- 여기만 바뀜
accessTtl
);

String refresh = jwtService.createRefreshToken(u.getId(), refreshTtl);

Expand All @@ -53,8 +56,10 @@ public AuthTokens issueFor(User u) {
.build();
refreshRepo.save(rt);

return new AuthTokens(access, now.plusSeconds(accessTtl).getEpochSecond(),
refresh, now.plusSeconds(refreshTtl).getEpochSecond());
return new AuthTokens(
access, now.plusSeconds(accessTtl).getEpochSecond(),
refresh, now.plusSeconds(refreshTtl).getEpochSecond()
);
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@
import org.springframework.web.filter.CorsFilter;

import java.security.interfaces.RSAPublicKey;
import java.util.List;

@Configuration
@EnableConfigurationProperties(AppProperties.class) // ✅ AppProperties 바인딩 활성화
@EnableConfigurationProperties(AppProperties.class)
public class SecurityConfig {

private final CustomOauth2UserService customOauth2UserService;
Expand All @@ -52,15 +51,18 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http,
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

.authorizeHttpRequests(auth -> auth
// Swagger / H2 / 로그인 등 공개
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/h2-console/**").permitAll()
.requestMatchers("/error").permitAll()
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/login", "/oauth2/**").permitAll()

.requestMatchers("/api/auth/refresh", "/api/auth/logout").permitAll()
.requestMatchers("/api/auth/me").authenticated()
// WS 핸드셰이크는 열어두고, STOMP CONNECT 에서 JWT 검증
.requestMatchers("/ws/**").permitAll()

// 인증 필요한 API
.requestMatchers("/api/auth/me").authenticated()
.requestMatchers("/api/kyc/**").authenticated()
.requestMatchers("/api/did/**").authenticated()
.requestMatchers("/api/resumes/**").authenticated()
Expand All @@ -72,6 +74,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http,
.requestMatchers(HttpMethod.GET, "/api/notifications", "/api/notifications/unread-count").authenticated()
.requestMatchers(HttpMethod.PATCH,"/api/notifications/**").authenticated()

// ✅ 채팅 REST는 인증 필수
.requestMatchers("/api/chat/**").authenticated()

// 나머지
.requestMatchers("/api/**").permitAll()
.anyRequest().permitAll()
)
Expand All @@ -84,7 +90,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http,
.failureHandler(customOAuth2FailureHandler)
)

.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> { }))
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> {})) // RS256 JWT 검증
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> {
res.setContentType("application/json");
Expand Down Expand Up @@ -119,15 +125,12 @@ public CookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationReque
return new CookieOAuth2AuthorizationRequestRepository("chainee.store");
}

/** ✅ CORS를 yml의 frontend-baseurls 기반으로 구성 */
@Bean
public CorsFilter corsFilter(AppProperties props) {
CorsConfiguration cfg = new CorsConfiguration();

for (String base : props.effectiveBases()) {
cfg.addAllowedOrigin(base); // 정확한 Origin만 허용
cfg.addAllowedOrigin(base);
}

cfg.addAllowedHeader("*");
cfg.addAllowedMethod("*");
cfg.setAllowCredentials(true);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.chaineeproject.chainee.config;

import com.chaineeproject.chainee.security.ws.HttpHandshakeJwtInterceptor;
import com.chaineeproject.chainee.security.ws.StompJwtChannelInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.*;

@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

private final StompJwtChannelInterceptor stompJwtChannelInterceptor;

@Override
public void registerStompEndpoints(StompEndpointRegistry reg) {
reg.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.addInterceptors(new HttpHandshakeJwtInterceptor()); // 쿼리스트링 token= 지원(옵션)
}

@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(stompJwtChannelInterceptor); // CONNECT의 Authorization: Bearer 처리
}

@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue");
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.chaineeproject.chainee.controller;

import com.chaineeproject.chainee.entity.Conversation;
import com.chaineeproject.chainee.entity.Message;
import com.chaineeproject.chainee.service.ChatService;
import com.chaineeproject.chainee.security.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/chat")
public class ChatController {

private final ChatService chatService;

public record StartReq(Long postId, Long applicantId) {}
public record SendReq(String content, String attachmentUrl) {}

@PostMapping("/conversations")
@Operation(summary = "대화 시작(구인자 전용)")
public ResponseEntity<Conversation> start(@AuthenticationPrincipal Jwt jwt,
@RequestBody StartReq req) {
Long uid = SecurityUtils.uidOrNull(jwt);
if (uid == null) return ResponseEntity.status(401).build();
var conv = chatService.startConversation(req.postId(), req.applicantId(), uid);
return ResponseEntity.ok(conv);
}

@GetMapping("/conversations/{conversationId}/messages")
@Operation(summary = "메시지 조회")
public ResponseEntity<Page<Message>> messages(@AuthenticationPrincipal Jwt jwt,
@PathVariable Long conversationId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
Long uid = SecurityUtils.uidOrNull(jwt);
if (uid == null) return ResponseEntity.status(401).build();
return ResponseEntity.ok(chatService.getMessages(conversationId, uid, page, size));
}

@PostMapping("/conversations/{conversationId}/messages")
@Operation(summary = "메시지 전송")
public ResponseEntity<Message> send(@AuthenticationPrincipal Jwt jwt,
@PathVariable Long conversationId,
@RequestBody SendReq req) {
Long uid = SecurityUtils.uidOrNull(jwt);
if (uid == null) return ResponseEntity.status(401).build();
var saved = chatService.sendMessage(conversationId, uid, req.content(), req.attachmentUrl());
return ResponseEntity.ok(saved);
}

@PostMapping("/conversations/{conversationId}/read")
@Operation(summary = "읽음 처리")
public ResponseEntity<Void> read(@AuthenticationPrincipal Jwt jwt,
@PathVariable Long conversationId) {
Long uid = SecurityUtils.uidOrNull(jwt);
if (uid == null) return ResponseEntity.status(401).build();
chatService.markRead(conversationId, uid);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.chaineeproject.chainee.controller;

import com.chaineeproject.chainee.entity.Message;
import com.chaineeproject.chainee.security.ws.WsPrincipal;
import com.chaineeproject.chainee.service.ChatService;
import lombok.RequiredArgsConstructor;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;

import java.security.Principal;

@Controller
@RequiredArgsConstructor
public class ChatWsController {

private final ChatService chatService;
private final SimpMessagingTemplate messagingTemplate;

public record WsSendPayload(Long conversationId, String content, String attachmentUrl) {}

@MessageMapping("/chat.send")
public void send(@Payload WsSendPayload p, Principal principal) {
Long senderId = extractUid(principal);
Message saved = chatService.sendMessage(p.conversationId(), senderId, p.content(), p.attachmentUrl());
messagingTemplate.convertAndSend("/topic/conversations/" + p.conversationId(), saved);
}

private Long extractUid(Principal principal) {
if (principal instanceof WsPrincipal ws) return ws.userId();
// 안전망: 그래도 없으면 거부
throw new SecurityException("NO_AUTH");
}
}
Loading