feat: 원격 서버 OAuth 키 설정 지원

This commit is contained in:
hehihoho3@gmail.com 2026-08-31 12:25:24 +09:00
parent d74bbaf657
commit a6c87ffbcb
4 changed files with 36 additions and 12 deletions

View File

@ -5,6 +5,7 @@ import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpServletRequest; 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;
@ -16,6 +17,8 @@ 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;
@ -27,6 +30,9 @@ 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();
@ -60,22 +66,32 @@ public class YoutubeOAuthController {
@GetMapping("/settings/status") @GetMapping("/settings/status")
@ResponseBody @ResponseBody
public ApiResponse<YoutubeOAuthLocalSettings.Status> settingsStatus() { public ApiResponse<SettingsStatus> settingsStatus() {
return ApiResponse.ok(localSettings.status()); YoutubeOAuthLocalSettings.Status status = localSettings.status();
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) { HttpServletRequest request) {
if (!request.getRemoteAddr().equals("127.0.0.1") && !request.getRemoteAddr().equals("0:0:0:0:0:0:0:1") requireSetupToken(request.getHeader("X-YouTube-Setup-Token"));
&& !request.getRemoteAddr().equals("::1")) {
throw new IllegalStateException("YouTube 키 설정은 서버 컴퓨터에서만 변경할 수 있습니다.");
}
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) {
@ -88,4 +104,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) { }
} }

View File

@ -11,3 +11,7 @@ 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.
YOUTUBE_OAUTH_SETTINGS_FILE: config/youtube-oauth.properties

View File

@ -55,6 +55,8 @@ 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}
# Python 마이크로서비스(자막 /transcript, Whisper 전사 /transcribe). 동일 호스트면 http://localhost:8000 로 오버라이드. # Python 마이크로서비스(자막 /transcript, Whisper 전사 /transcribe). 동일 호스트면 http://localhost:8000 로 오버라이드.
python: python:

View File

@ -131,10 +131,11 @@
<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에서 제외된 서버 로컬 설정에 저장됩니다. 보안을 위해 이 화면은 서버 컴퓨터에서 접속했을 때만 저장할 수 있습니다.</div> <div class="us-help">키는 브라우저나 Git에 보관하지 않습니다. 서버의 <code>YOUTUBE_OAUTH_SETTINGS_FILE</code> 경로에 저장되므로 운영 환경에서는 영속 볼륨 경로를 지정하세요.</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>
@ -169,21 +170,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=[]; let connections=[], setupTokenConfigured=false;
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;if(body.data.redirectUri)document.getElementById('oauthRedirectUri').value=body.data.redirectUri;}catch(e){oauthConfigured=false;} 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;}
} }
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');setTimeout(()=>document.getElementById('oauthClientId').focus(),0);} function openOAuthSettings(){document.getElementById('oauthModal').classList.add('open');if(!setupTokenConfigured)showToast('서버에 YOUTUBE_SETUP_TOKEN 환경변수를 먼저 설정하세요.');setTimeout(()=>document.getElementById('oauthSetupToken').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'},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','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 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='';oauthConfigured=true;location.href='/api/youtube/oauth/start'; document.getElementById('oauthClientSecret').value='';document.getElementById('oauthSetupToken').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(){