Shell 플레이어 연동 가이드
데스크톱 보안 실행 환경(StarPlayer Shell / StarPlayerApp)을 학습 사이트에서 호출하는 방법입니다. URL scheme 두 가지를 다룹니다.
대상 · LMS/서비스 백엔드 개발자
두 가지 연동 방식
| 스킴 | 하는 일 | 언제 |
|---|---|---|
starplayer:// | 콘텐츠 다운로드 · VOD/라이브 재생을 앱에 직접 지시 | 강의를 내려받거나 원격 매니페스트를 앱에서 바로 재생 |
starplayerapp:// | 웹 재생 페이지를 보안 셸 안에서 연다 (세션 핸드셰이크) | 기존 웹 플레이어 화면을 그대로 보안 환경에서 감싸 재생 |
starplayer:// 딥링크(다운로드/재생/라이브)의 파라미터는 Shell 제품 페이지와
재생 토큰 연동 가이드를 참고하세요. 이 문서는 starplayerapp:// 보안 셸 웹 재생을 중심으로 설명합니다.
starplayerapp:// — 보안 셸 웹 재생 핸드셰이크
이미 있는 웹 재생 페이지를 보안 셸 안에서 열어, 변조 방지·프로세스 감시·Device Binding을 적용합니다. 페이지가 링크만 만들면 되고, 실제 감싸기는 앱이 처리합니다. 아무나 임의 URL을 여는 것을 막기 위해 1회용 토큰 + HMAC 서명으로 검증합니다.
POST /api/app-launch-token { play_url, user_id } — 30초 1회용 토큰 발급(IP 바인딩), starplayerapp:// launchUrl 반환
location.href = launchUrl (starplayerapp://play?token=&url=&authUrl=…)
앱이 authUrl 로 POST — 헤더 X-App-Signature: HMAC-SHA256(APP_SECRET, token). 서명·만료·IP 검증 후 세션 쿠키 발급
앱이 받은 세션 쿠키로 play_url(웹 재생 페이지)을 보안 셸 안에서 연다
APP_SECRET)는 앱 빌드와 같은 값이어야 하고 서버에만 둡니다. 실행 토큰은 30초·1회용·IP 바인딩이라 재사용·탈취를 막습니다./api/app-launch-token 과 /api/app-launch-verify 는 고객사가 자사 백엔드에 직접 구현하는 주소입니다(아래 예제). 경로 이름은 예시일 뿐 자유롭게 정해도 되며, drm.starplayer.net 같은 StarPlayer 서버로 호출하는 것이 아닙니다. 앱은 ①에서 내려준 authUrl(= 고객사 자사 /api/app-launch-verify 주소)로 ②를 호출합니다.① 실행 토큰 발급 — POST /api/app-launch-token (고객사 자사 백엔드)
# 요청 (브라우저 → 고객사 자사 백엔드. StarPlayer 서버 아님) POST /api/app-launch-token # ← 자사 도메인의 엔드포인트 (경로 이름은 자유) Content-Type: application/json { "play_url": "https://lms.example.com/player/watch/12345", "user_id": "student001" } # 응답 (200) { "token": "9f1c...-uuid", "expiresIn": 30, "launchUrl": "starplayerapp://play?token=9f1c...&url=https%3A%2F%2F...%2Fwatch%2F12345&authUrl=https%3A%2F%2F...%2Fapi%2Fapp-launch-verify&referer=https%3A%2F%2Flms.example.com" }
| 파라미터 | 설명 |
|---|---|
play_url | 필수. 보안 셸 안에서 열 웹 재생 페이지의 절대 URL |
user_id | 사용자 식별자. 검증 성공 시 세션 사용자로 실린다 |
launchUrl 의 authUrl 은 앱이 ②에서 부를 검증 주소(/api/app-launch-verify)입니다. 서버가 자동으로 채웁니다.
② 앱 검증 — POST /api/app-launch-verify (고객사 자사 백엔드)
네이티브 앱이 ①에서 받은 authUrl(= 고객사 자사 /api/app-launch-verify)로 부릅니다(브라우저가 아니므로 Origin 검사 대상이 아님). 앱이 APP_SECRET 으로 서명한 값을 고객사 백엔드가 대조합니다.
# 요청 (앱 → 고객사 자사 백엔드. authUrl 로 지정된 주소) POST /api/app-launch-verify Content-Type: application/json X-App-Signature: <hex(HMAC-SHA256(APP_SECRET, token))> ← 앱이 서명 { "token": "9f1c...-uuid" } # 응답 (200) — 앱이 이 쿠키로 play_url 을 보안 셸에서 연다 { "user": { "id": "student001", "name": null }, "url": "https://lms.example.com/player/watch/12345", "auth": { "type": "cookie", "cookies": [{ "name": "STAR_SESSION", "value": "sess_...", "domain": "lms.example.com", "path": "/", "secure": true, "httpOnly": true, "sameSite": "lax", "expire": 86400 }] } }
X-App-Signature)이 APP_SECRET 으로 맞는지 ② 토큰이 존재하고 만료 전인지(30초) ③ 발급 때와 IP가 같은지. 통과하면 토큰을 즉시 폐기(1회용)하고 세션 쿠키를 내려줍니다. 구버전 앱은 token:timestamp 를 서명하고 X-App-Timestamp 를 함께 보냅니다 — 둘 다 받도록 하세요.서버측 구현
Classic ASP(VBScript)는 HMAC-SHA256 내장이 없어 별도 컴포넌트가 필요합니다 — .NET 환경이면 위 ASP.NET(C#) 예제를 권장합니다.
// APP_SECRET 는 앱 빌드와 같은 값. 서버에만 둔다. const crypto = require('crypto'); const APP_SECRET = process.env.APP_SECRET; const TTL = 30; // 초 — 1회용 토큰 수명 const launchTokens = new Map(); // token → { userId, url, ip, expiresAt } // ① 실행 토큰 발급 app.post('/api/app-launch-token', (req, res) => { const { play_url, user_id } = req.body; if (!play_url) return res.status(400).json({ error: 'missing_play_url' }); const token = crypto.randomUUID(); launchTokens.set(token, { userId: user_id || 'demo', url: play_url, ip: req.ip, expiresAt: Date.now() + TTL * 1000 }); const q = new URLSearchParams({ token, url: play_url, authUrl: `${req.protocol}://${req.get('host')}/api/app-launch-verify`, referer: new URL(play_url).origin, }); res.json({ token, expiresIn: TTL, launchUrl: `starplayerapp://play?${q}` }); }); // ② 앱 서명 검증 (Origin 검사 제외 — 네이티브 앱 호출) app.post('/api/app-launch-verify', (req, res) => { const token = req.body.token; const sig = req.headers['x-app-signature']; const ts = req.headers['x-app-timestamp']; // 구버전 호환 const target = (ts != null) ? `${token}:${ts}` : token; const expected = crypto.createHmac('sha256', APP_SECRET).update(target).digest('hex'); if (!sig || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) return res.status(403).json({ error: '서명이 유효하지 않습니다.' }); const sess = launchTokens.get(token); if (!sess) return res.status(401).json({ error: '유효하지 않은 토큰' }); if (Date.now() > sess.expiresAt) return res.status(401).json({ error: '만료된 토큰' }); if (sess.ip !== req.ip) return res.status(401).json({ error: 'IP 불일치' }); launchTokens.delete(token); // 1회용 const host = new URL(sess.url).hostname; res.json({ user: { id: sess.userId, name: null }, url: sess.url, auth: { type: 'cookie', cookies: [{ name: 'STAR_SESSION', value: 'sess_' + crypto.randomUUID(), domain: host, path: '/', secure: true, httpOnly: true, sameSite: 'lax', expire: 86400, }]}, }); });
<?php // APP_SECRET 는 앱 빌드와 같은 값. 서버에만 둔다. const TTL = 30; $APP_SECRET = getenv('APP_SECRET'); // 토큰 저장소는 예시로 파일/DB/redis 등 서버 공유 저장소를 쓴다 (아래는 개념용) function store_put($token, $data) { /* redis/DB 에 TTL 로 저장 */ } function store_take($token) { /* 조회 후 삭제(1회용) 하여 반환, 없으면 null */ } $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $in = json_decode(file_get_contents('php://input'), true) ?: []; // ① 실행 토큰 발급 if ($path === '/api/app-launch-token') { header('Content-Type: application/json'); if (empty($in['play_url'])) { http_response_code(400); echo '{"error":"missing_play_url"}'; exit; } $token = bin2hex(random_bytes(16)); store_put($token, [ 'userId' => $in['user_id'] ?? 'demo', 'url' => $in['play_url'], 'ip' => $_SERVER['REMOTE_ADDR'], 'exp' => time() + TTL, ]); $proto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; $authUrl = $proto . '://' . $_SERVER['HTTP_HOST'] . '/api/app-launch-verify'; $q = http_build_query([ 'token' => $token, 'url' => $in['play_url'], 'authUrl' => $authUrl, 'referer' => parse_url($in['play_url'], PHP_URL_SCHEME) . '://' . parse_url($in['play_url'], PHP_URL_HOST), ]); echo json_encode(['token' => $token, 'expiresIn' => TTL, 'launchUrl' => "starplayerapp://play?$q"]); exit; } // ② 앱 서명 검증 if ($path === '/api/app-launch-verify') { header('Content-Type: application/json'); $token = $in['token'] ?? ''; $sig = $_SERVER['HTTP_X_APP_SIGNATURE'] ?? ''; $ts = $_SERVER['HTTP_X_APP_TIMESTAMP'] ?? null; // 구버전 호환 $target = ($ts !== null) ? "$token:$ts" : $token; $expected = hash_hmac('sha256', $target, $APP_SECRET); if (!$sig || !hash_equals($expected, $sig)) { http_response_code(403); echo '{"error":"bad_signature"}'; exit; } $sess = store_take($token); if (!$sess) { http_response_code(401); echo '{"error":"invalid_token"}'; exit; } if (time() > $sess['exp']) { http_response_code(401); echo '{"error":"expired"}'; exit; } if ($sess['ip'] !== $_SERVER['REMOTE_ADDR']) { http_response_code(401); echo '{"error":"ip_mismatch"}'; exit; } $host = parse_url($sess['url'], PHP_URL_HOST); echo json_encode([ 'user' => ['id' => $sess['userId'], 'name' => null], 'url' => $sess['url'], 'auth' => ['type' => 'cookie', 'cookies' => [[ 'name' => 'STAR_SESSION', 'value' => 'sess_' . bin2hex(random_bytes(12)), 'domain' => $host, 'path' => '/', 'secure' => true, 'httpOnly' => true, 'sameSite' => 'lax', 'expire' => 86400, ]]], ]); exit; }
// APP_SECRET 는 앱 빌드와 같은 값. 서버에만 둔다. const int TTL = 30; static readonly string AppSecret = Environment.GetEnvironmentVariable("APP_SECRET"); // 다중 인스턴스면 분산 캐시(Redis 등)로 교체 static readonly ConcurrentDictionary<string, (string userId, string url, string ip, long exp)> Tokens = new(); // ① 실행 토큰 발급 app.MapPost("/api/app-launch-token", (HttpContext ctx, LaunchReq req) => { if (string.IsNullOrEmpty(req.play_url)) return Results.BadRequest(new { error = "missing_play_url" }); var token = Guid.NewGuid().ToString(); var ip = ctx.Connection.RemoteIpAddress?.ToString(); Tokens[token] = (req.user_id ?? "demo", req.play_url, ip, DateTimeOffset.UtcNow.ToUnixTimeSeconds() + TTL); var origin = new Uri(req.play_url).GetLeftPart(UriPartial.Authority); var authUrl = $"{ctx.Request.Scheme}://{ctx.Request.Host}/api/app-launch-verify"; var q = $"token={Uri.EscapeDataString(token)}&url={Uri.EscapeDataString(req.play_url)}" + $"&authUrl={Uri.EscapeDataString(authUrl)}&referer={Uri.EscapeDataString(origin)}"; return Results.Ok(new { token, expiresIn = TTL, launchUrl = $"starplayerapp://play?{q}" }); }); // ② 앱 서명 검증 app.MapPost("/api/app-launch-verify", (HttpContext ctx, VerifyReq body) => { var sig = ctx.Request.Headers["X-App-Signature"].ToString(); var ts = ctx.Request.Headers["X-App-Timestamp"].ToString(); // 구버전 호환 var target = string.IsNullOrEmpty(ts) ? body.token : $"{body.token}:{ts}"; using var h = new HMACSHA256(Encoding.UTF8.GetBytes(AppSecret)); var expected = Convert.ToHexString(h.ComputeHash(Encoding.UTF8.GetBytes(target))).ToLower(); if (string.IsNullOrEmpty(sig) || !CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(sig))) return Results.StatusCode(403); if (!Tokens.TryRemove(body.token, out var s)) return Results.StatusCode(401); // 1회용 if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > s.exp) return Results.StatusCode(401); if (s.ip != ctx.Connection.RemoteIpAddress?.ToString()) return Results.StatusCode(401); var host = new Uri(s.url).Host; return Results.Ok(new { user = new { id = s.userId, name = (string)null }, url = s.url, auth = new { type = "cookie", cookies = new[] { new { name = "STAR_SESSION", value = "sess_" + Guid.NewGuid(), domain = host, path = "/", secure = true, httpOnly = true, sameSite = "lax", expire = 86400 } } }, }); }); record LaunchReq(string play_url, string user_id); record VerifyReq(string token);
# APP_SECRET 는 앱 빌드와 같은 값. 서버에만 둔다. import os, time, uuid, hmac, hashlib from urllib.parse import urlencode, urlparse from flask import Flask, request, jsonify app = Flask(__name__) APP_SECRET = os.environ["APP_SECRET"].encode() TTL = 30 tokens = {} # token -> {userId,url,ip,exp} (다중 인스턴스면 redis 등 공유 저장소) # ① 실행 토큰 발급 @app.post("/api/app-launch-token") def launch_token(): body = request.get_json(silent=True) or {} if not body.get("play_url"): return jsonify(error="missing_play_url"), 400 token = uuid.uuid4().hex tokens[token] = {"userId": body.get("user_id", "demo"), "url": body["play_url"], "ip": request.remote_addr, "exp": time.time() + TTL} u = urlparse(body["play_url"]) q = urlencode({"token": token, "url": body["play_url"], "authUrl": f"{request.scheme}://{request.host}/api/app-launch-verify", "referer": f"{u.scheme}://{u.netloc}"}) return jsonify(token=token, expiresIn=TTL, launchUrl=f"starplayerapp://play?{q}") # ② 앱 서명 검증 @app.post("/api/app-launch-verify") def launch_verify(): body = request.get_json(silent=True) or {} token = body.get("token", "") sig = request.headers.get("X-App-Signature", "") ts = request.headers.get("X-App-Timestamp") # 구버전 호환 target = f"{token}:{ts}" if ts is not None else token expected = hmac.new(APP_SECRET, target.encode(), hashlib.sha256).hexdigest() if not sig or not hmac.compare_digest(expected, sig): return jsonify(error="bad_signature"), 403 sess = tokens.pop(token, None) # 1회용 if not sess: return jsonify(error="invalid_token"), 401 if time.time() > sess["exp"]: return jsonify(error="expired"), 401 if sess["ip"] != request.remote_addr: return jsonify(error="ip_mismatch"), 401 host = urlparse(sess["url"]).hostname return jsonify(user={"id": sess["userId"], "name": None}, url=sess["url"], auth={"type": "cookie", "cookies": [{"name": "STAR_SESSION", "value": "sess_" + uuid.uuid4().hex, "domain": host, "path": "/", "secure": True, "httpOnly": True, "sameSite": "lax", "expire": 86400}]})
// APP_SECRET 는 앱 빌드와 같은 값. 서버에만 둔다. static final int TTL = 30; static final String APP_SECRET = System.getenv("APP_SECRET"); // 다중 인스턴스면 Redis 등 공유 저장소로 교체 static final Map<String, String[]> TOKENS = new ConcurrentHashMap<>(); // token -> [userId,url,ip,exp] // ① 실행 토큰 발급 @PostMapping("/api/app-launch-token") public Map<String,Object> token(@RequestBody Map<String,String> req, HttpServletRequest http) { String url = req.get("play_url"); if (url == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing_play_url"); String token = UUID.randomUUID().toString(); TOKENS.put(token, new String[]{ req.getOrDefault("user_id", "demo"), url, http.getRemoteAddr(), String.valueOf(Instant.now().getEpochSecond() + TTL) }); URI u = URI.create(url); String q = "token=" + enc(token) + "&url=" + enc(url) + "&authUrl=" + enc(http.getScheme() + "://" + http.getHeader("host") + "/api/app-launch-verify") + "&referer=" + enc(u.getScheme() + "://" + u.getAuthority()); return Map.of("token", token, "expiresIn", TTL, "launchUrl", "starplayerapp://play?" + q); } // ② 앱 서명 검증 @PostMapping("/api/app-launch-verify") public Map<String,Object> verify(@RequestBody Map<String,String> body, @RequestHeader(value="X-App-Signature", required=false) String sig, @RequestHeader(value="X-App-Timestamp", required=false) String ts, // 구버전 호환 HttpServletRequest http) throws Exception { String token = body.getOrDefault("token", ""); String target = (ts != null) ? token + ":" + ts : token; Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(APP_SECRET.getBytes(), "HmacSHA256")); String expected = HexFormat.of().formatHex(mac.doFinal(target.getBytes())); if (sig == null || !MessageDigest.isEqual(expected.getBytes(), sig.getBytes())) throw new ResponseStatusException(HttpStatus.FORBIDDEN); String[] s = TOKENS.remove(token); // 1회용 if (s == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (Instant.now().getEpochSecond() > Long.parseLong(s[3])) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (!s[2].equals(http.getRemoteAddr())) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); String host = URI.create(s[1]).getHost(); return Map.of("user", Map.of("id", s[0]), "url", s[1], "auth", Map.of("type", "cookie", "cookies", List.of(Map.of( "name", "STAR_SESSION", "value", "sess_" + UUID.randomUUID(), "domain", host, "path", "/", "secure", true, "httpOnly", true, "sameSite", "lax", "expire", 86400)))); } static String enc(String v) { return URLEncoder.encode(v, StandardCharsets.UTF_8); }
브라우저측 — 앱 호출
토큰을 받아 launchUrl 로 앱을 엽니다. 앱이 없으면 아무 일도 안 일어나므로 설치 안내로 폴백합니다.
async function openInShell(playUrl, userId) {
// ① 백엔드에서 1회용 실행 토큰 발급
const r = await fetch('/api/app-launch-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ play_url: playUrl, user_id: userId }),
});
const data = await r.json(); // { token, expiresIn, launchUrl }
// ② 앱 실행 — 1.5초 내 화면 전환 없으면 미설치로 보고 설치 안내
let hidden = false;
const onHide = () => (hidden = true);
document.addEventListener('visibilitychange', onHide, { once: true });
window.addEventListener('pagehide', onHide, { once: true });
setTimeout(() => {
if (!hidden && document.visibilityState === 'visible'
&& confirm('StarPlayer 앱이 설치되어 있지 않은 것 같습니다. 설치 페이지로 이동할까요?')) {
location.href = 'https://shell.starplayer.net'; // 설치 안내
}
}, 1500);
location.href = data.launchUrl; // starplayerapp://play?…
}
starplayer:// 딥링크(다운로드·재생) 데모는 Shell 제품 페이지 참고.
자주 겪는 문제
| 증상 | 원인 | 해결 |
|---|---|---|
| 검증에서 "서명이 유효하지 않습니다" | APP_SECRET 이 앱 빌드와 다름 | 앱과 서버의 시크릿을 같은 값으로 맞춘다 |
| "만료된 토큰" | 발급 후 30초 초과 | 링크 생성 직후 바로 앱을 호출한다 |
| "IP 불일치" | 토큰 발급·검증의 클라이언트 IP가 다름 | 프록시 뒤라면 X-Forwarded-For 를 신뢰하도록 설정 |
| 토큰 저장소가 비어 재검증 실패 | 인스턴스별 메모리 저장 | 다중 인스턴스면 Redis 등 공유 저장소를 쓴다 |
| 링크를 눌러도 앱이 안 열림 | 앱 미설치 | 설치 안내(shell.starplayer.net)로 폴백 |
적용 전 체크리스트
☐ APP_SECRET 이 앱 빌드와 동일하고 서버에만 있다 |
| ☐ 실행 토큰은 30초·1회용·IP 바인딩으로 관리한다 |
| ☐ 토큰 저장소가 다중 인스턴스에서 공유된다 (Redis/DB) |
☐ 서명 검증은 token 과 token:timestamp(구버전)를 모두 받는다 |
☐ play_url 은 절대 URL, 세션 쿠키 도메인은 그 호스트에 맞춘다 |
| ☐ 앱 미설치 시 설치 안내가 뜬다 |