fix: 유튜브 OAuth 최초 설정 흐름 단순화

This commit is contained in:
hehihoho3@gmail.com 2026-08-31 12:42:08 +09:00
parent a6c87ffbcb
commit eef46aec9b
5 changed files with 15 additions and 34 deletions

View File

@ -2,10 +2,8 @@ package com.hlab.yanalyst.domain.upload;
import com.hlab.yanalyst.global.common.ApiResponse; import com.hlab.yanalyst.global.common.ApiResponse;
import jakarta.servlet.http.HttpSession; import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -17,8 +15,6 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseBody;
import java.net.URI; import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -30,9 +26,6 @@ public class YoutubeOAuthController {
private final YoutubeOAuthService service; private final YoutubeOAuthService service;
private final YoutubeOAuthLocalSettings localSettings; private final YoutubeOAuthLocalSettings localSettings;
@Value("${youtube.oauth.setup-token:}")
private String setupToken;
@GetMapping("/oauth/start") @GetMapping("/oauth/start")
public ResponseEntity<Void> start(HttpSession session) { public ResponseEntity<Void> start(HttpSession session) {
String state = UUID.randomUUID().toString(); String state = UUID.randomUUID().toString();
@ -68,30 +61,19 @@ public class YoutubeOAuthController {
@ResponseBody @ResponseBody
public ApiResponse<SettingsStatus> settingsStatus() { public ApiResponse<SettingsStatus> settingsStatus() {
YoutubeOAuthLocalSettings.Status status = localSettings.status(); YoutubeOAuthLocalSettings.Status status = localSettings.status();
return ApiResponse.ok(new SettingsStatus(status.configured(), status.redirectUri(), return ApiResponse.ok(new SettingsStatus(status.configured(), status.redirectUri()));
setupToken != null && !setupToken.isBlank()));
} }
@PostMapping("/settings") @PostMapping("/settings")
@ResponseBody @ResponseBody
public ApiResponse<YoutubeOAuthLocalSettings.Status> saveSettings(@RequestBody SettingsRequest body, public ApiResponse<YoutubeOAuthLocalSettings.Status> saveSettings(@RequestBody SettingsRequest body) {
HttpServletRequest request) { if (localSettings.status().configured()) {
requireSetupToken(request.getHeader("X-YouTube-Setup-Token")); throw new IllegalArgumentException("YouTube OAuth 설정이 이미 완료되었습니다.");
}
localSettings.save(body.clientId(), body.clientSecret(), body.redirectUri()); localSettings.save(body.clientId(), body.clientSecret(), body.redirectUri());
return ApiResponse.ok(localSettings.status()); return ApiResponse.ok(localSettings.status());
} }
private void requireSetupToken(String provided) {
if (setupToken == null || setupToken.isBlank()) {
throw new IllegalStateException("서버에 YOUTUBE_SETUP_TOKEN을 먼저 설정해 주세요.");
}
byte[] expected = setupToken.getBytes(StandardCharsets.UTF_8);
byte[] actual = (provided == null ? "" : provided).getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expected, actual)) {
throw new IllegalArgumentException("관리자 설정 토큰이 올바르지 않습니다.");
}
}
@DeleteMapping("/connections/{id}") @DeleteMapping("/connections/{id}")
@ResponseBody @ResponseBody
public ApiResponse<Void> disconnect(@PathVariable Long id) { public ApiResponse<Void> disconnect(@PathVariable Long id) {
@ -104,5 +86,5 @@ public class YoutubeOAuthController {
} }
public record SettingsRequest(String clientId, String clientSecret, String redirectUri) { } public record SettingsRequest(String clientId, String clientSecret, String redirectUri) { }
public record SettingsStatus(boolean configured, String redirectUri, boolean setupTokenConfigured) { } public record SettingsStatus(boolean configured, String redirectUri) { }
} }

View File

@ -42,6 +42,9 @@ public class YoutubeOAuthLocalSettings {
} }
public synchronized void save(String clientId, String clientSecret, String redirectUri) { public synchronized void save(String clientId, String clientSecret, String redirectUri) {
if (Files.exists(path)) {
throw new IllegalArgumentException("YouTube OAuth 설정이 이미 완료되었습니다.");
}
if (clientId == null || clientId.isBlank() || clientSecret == null || clientSecret.isBlank()) { if (clientId == null || clientId.isBlank() || clientSecret == null || clientSecret.isBlank()) {
throw new IllegalArgumentException("Client ID와 Client Secret을 모두 입력해 주세요."); throw new IllegalArgumentException("Client ID와 Client Secret을 모두 입력해 주세요.");
} }

View File

@ -11,7 +11,5 @@ YOUTUBE_OAUTH_CLIENT_SECRET: <google-oauth-web-client-secret>
YOUTUBE_OAUTH_REDIRECT_URI: http://localhost:8088/api/youtube/oauth/callback YOUTUBE_OAUTH_REDIRECT_URI: http://localhost:8088/api/youtube/oauth/callback
# Generate once: openssl rand -base64 32 # Generate once: openssl rand -base64 32
YOUTUBE_OAUTH_ENCRYPTION_KEY: <base64-encoded-32-byte-key> YOUTUBE_OAUTH_ENCRYPTION_KEY: <base64-encoded-32-byte-key>
# Required when saving OAuth credentials from the web UI on a remote server.
YOUTUBE_SETUP_TOKEN: <long-random-admin-setup-token>
# Point this at a persistent mounted volume in production. # Point this at a persistent mounted volume in production.
YOUTUBE_OAUTH_SETTINGS_FILE: config/youtube-oauth.properties YOUTUBE_OAUTH_SETTINGS_FILE: config/youtube-oauth.properties

View File

@ -55,7 +55,6 @@ youtube:
client-secret: ${YOUTUBE_OAUTH_CLIENT_SECRET:} client-secret: ${YOUTUBE_OAUTH_CLIENT_SECRET:}
redirect-uri: ${YOUTUBE_OAUTH_REDIRECT_URI:http://localhost:8088/api/youtube/oauth/callback} redirect-uri: ${YOUTUBE_OAUTH_REDIRECT_URI:http://localhost:8088/api/youtube/oauth/callback}
encryption-key: ${YOUTUBE_OAUTH_ENCRYPTION_KEY:} encryption-key: ${YOUTUBE_OAUTH_ENCRYPTION_KEY:}
setup-token: ${YOUTUBE_SETUP_TOKEN:}
settings-file: ${YOUTUBE_OAUTH_SETTINGS_FILE:config/youtube-oauth.properties} settings-file: ${YOUTUBE_OAUTH_SETTINGS_FILE:config/youtube-oauth.properties}
# Python 마이크로서비스(자막 /transcript, Whisper 전사 /transcribe). 동일 호스트면 http://localhost:8000 로 오버라이드. # Python 마이크로서비스(자막 /transcript, Whisper 전사 /transcribe). 동일 호스트면 http://localhost:8000 로 오버라이드.

View File

@ -131,11 +131,10 @@
<div class="us-dialog-head"><div><div class="us-kicker">One-time setup</div><h2 id="oauthTitle">YouTube API 키 연결</h2><div class="us-muted">Google Cloud의 웹 OAuth 클라이언트 정보를 한 번만 입력하세요.</div></div><button class="us-button" type="button" aria-label="닫기" onclick="closeOAuthSettings()">×</button></div> <div class="us-dialog-head"><div><div class="us-kicker">One-time setup</div><h2 id="oauthTitle">YouTube API 키 연결</h2><div class="us-muted">Google Cloud의 웹 OAuth 클라이언트 정보를 한 번만 입력하세요.</div></div><button class="us-button" type="button" aria-label="닫기" onclick="closeOAuthSettings()">×</button></div>
<form class="us-dialog-body" onsubmit="saveOAuthSettings(event)"> <form class="us-dialog-body" onsubmit="saveOAuthSettings(event)">
<div class="us-help">Google Cloud Console에서 <b>YouTube Data API v3</b>를 사용 설정하고 OAuth 클라이언트 유형을 <b>웹 애플리케이션</b>으로 만드세요. 승인된 리디렉션 URI에는 아래 콜백 주소를 그대로 등록해야 합니다.</div> <div class="us-help">Google Cloud Console에서 <b>YouTube Data API v3</b>를 사용 설정하고 OAuth 클라이언트 유형을 <b>웹 애플리케이션</b>으로 만드세요. 승인된 리디렉션 URI에는 아래 콜백 주소를 그대로 등록해야 합니다.</div>
<div class="us-field"><label for="oauthSetupToken">관리자 설정 토큰</label><input id="oauthSetupToken" type="password" autocomplete="off" placeholder="서버의 YOUTUBE_SETUP_TOKEN" required></div>
<div class="us-field"><label for="oauthClientId">Client ID</label><input id="oauthClientId" autocomplete="off" placeholder="000000000000-….apps.googleusercontent.com" required></div> <div class="us-field"><label for="oauthClientId">Client ID</label><input id="oauthClientId" autocomplete="off" placeholder="000000000000-….apps.googleusercontent.com" required></div>
<div class="us-field"><label for="oauthClientSecret">Client Secret</label><input id="oauthClientSecret" type="password" autocomplete="new-password" placeholder="GOCSPX-…" required></div> <div class="us-field"><label for="oauthClientSecret">Client Secret</label><input id="oauthClientSecret" type="password" autocomplete="new-password" placeholder="GOCSPX-…" required></div>
<div class="us-field"><label for="oauthRedirectUri">승인된 리디렉션 URI</label><input id="oauthRedirectUri" type="url" value="http://localhost:8088/api/youtube/oauth/callback" required></div> <div class="us-field"><label for="oauthRedirectUri">승인된 리디렉션 URI</label><input id="oauthRedirectUri" type="url" value="http://localhost:8088/api/youtube/oauth/callback" required></div>
<div class="us-help">키는 브라우저나 Git에 보관하지 않습니다. 서버의 <code>YOUTUBE_OAUTH_SETTINGS_FILE</code> 경로에 저장되므로 운영 환경에서는 영속 볼륨 경로를 지정하세요.</div> <div class="us-help">키는 브라우저나 Git에 보관하지 않습니다. 최초 설정 후에는 화면/API에서 값을 읽거나 덮어쓸 수 없으며, 저장 직후 Google 로그인으로 이동합니다.</div>
<div class="us-dialog-actions"><button class="us-button" type="button" onclick="closeOAuthSettings()">취소</button><button class="us-button us-primary" id="oauthSaveButton" type="submit">저장하고 채널 연결</button></div> <div class="us-dialog-actions"><button class="us-button" type="button" onclick="closeOAuthSettings()">취소</button><button class="us-button us-primary" id="oauthSaveButton" type="submit">저장하고 채널 연결</button></div>
</form> </form>
</div> </div>
@ -170,21 +169,21 @@
document.getElementById('folderFallback').click(); document.getElementById('folderFallback').click();
} }
document.getElementById('folderFallback').addEventListener('change',e=>{const files=[...e.target.files].filter(f=>f.type.startsWith('video/'));videos=files.map((f,i)=>({id:Date.now()+i,name:f.name,meta:(f.size/1048576).toFixed(1)+' MB · 로컬 영상',status:'wait',label:'업로드 대기'}));document.getElementById('folderTitle').textContent=e.target.files[0]?.webkitRelativePath.split('/')[0]||'선택한 폴더';document.getElementById('folderPath').textContent='권한 연결됨 · '+videos.length+'개 영상 발견';renderVideos();}); document.getElementById('folderFallback').addEventListener('change',e=>{const files=[...e.target.files].filter(f=>f.type.startsWith('video/'));videos=files.map((f,i)=>({id:Date.now()+i,name:f.name,meta:(f.size/1048576).toFixed(1)+' MB · 로컬 영상',status:'wait',label:'업로드 대기'}));document.getElementById('folderTitle').textContent=e.target.files[0]?.webkitRelativePath.split('/')[0]||'선택한 폴더';document.getElementById('folderPath').textContent='권한 연결됨 · '+videos.length+'개 영상 발견';renderVideos();});
let connections=[], setupTokenConfigured=false; let connections=[];
let oauthConfigured=false; let oauthConfigured=false;
async function loadOAuthStatus(){ async function loadOAuthStatus(){
try{const response=await fetch('/api/youtube/settings/status');const body=await response.json();oauthConfigured=!!body.data.configured;setupTokenConfigured=!!body.data.setupTokenConfigured;if(body.data.redirectUri)document.getElementById('oauthRedirectUri').value=body.data.redirectUri;}catch(e){oauthConfigured=false;setupTokenConfigured=false;} try{const response=await fetch('/api/youtube/settings/status');const body=await response.json();oauthConfigured=!!body.data.configured;if(body.data.redirectUri)document.getElementById('oauthRedirectUri').value=body.data.redirectUri;}catch(e){oauthConfigured=false;}
} }
async function connectYoutube(){await loadOAuthStatus();if(!oauthConfigured){openOAuthSettings();return;}location.href='/api/youtube/oauth/start';} async function connectYoutube(){await loadOAuthStatus();if(!oauthConfigured){openOAuthSettings();return;}location.href='/api/youtube/oauth/start';}
function openOAuthSettings(){document.getElementById('oauthModal').classList.add('open');if(!setupTokenConfigured)showToast('서버에 YOUTUBE_SETUP_TOKEN 환경변수를 먼저 설정하세요.');setTimeout(()=>document.getElementById('oauthSetupToken').focus(),0);} function openOAuthSettings(){document.getElementById('oauthModal').classList.add('open');setTimeout(()=>document.getElementById('oauthClientId').focus(),0);}
function closeOAuthSettings(){document.getElementById('oauthModal').classList.remove('open');} function closeOAuthSettings(){document.getElementById('oauthModal').classList.remove('open');}
async function saveOAuthSettings(event){ async function saveOAuthSettings(event){
event.preventDefault(); event.preventDefault();
const button=document.getElementById('oauthSaveButton');button.disabled=true;button.textContent='안전하게 저장 중…'; const button=document.getElementById('oauthSaveButton');button.disabled=true;button.textContent='안전하게 저장 중…';
try{ try{
const response=await fetch('/api/youtube/settings',{method:'POST',headers:{'Content-Type':'application/json','X-YouTube-Setup-Token':document.getElementById('oauthSetupToken').value},body:JSON.stringify({clientId:document.getElementById('oauthClientId').value.trim(),clientSecret:document.getElementById('oauthClientSecret').value.trim(),redirectUri:document.getElementById('oauthRedirectUri').value.trim()})}); const response=await fetch('/api/youtube/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clientId:document.getElementById('oauthClientId').value.trim(),clientSecret:document.getElementById('oauthClientSecret').value.trim(),redirectUri:document.getElementById('oauthRedirectUri').value.trim()})});
const body=await response.json();if(!response.ok||!body.success)throw new Error(body.message||'설정 저장 실패'); const body=await response.json();if(!response.ok||!body.success)throw new Error(body.message||'설정 저장 실패');
document.getElementById('oauthClientSecret').value='';document.getElementById('oauthSetupToken').value='';oauthConfigured=true;location.href='/api/youtube/oauth/start'; document.getElementById('oauthClientSecret').value='';oauthConfigured=true;location.href='/api/youtube/oauth/start';
}catch(e){showToast(e.message||'키 설정을 저장하지 못했습니다.');button.disabled=false;button.textContent='저장하고 채널 연결';} }catch(e){showToast(e.message||'키 설정을 저장하지 못했습니다.');button.disabled=false;button.textContent='저장하고 채널 연결';}
} }
async function loadConnections(){ async function loadConnections(){