SSO 연동 가이드
파트너 서비스(예: 핑거푸시)에 로그인한 사용자가 FINGERPUSH.LINK 콘솔에 별도 회원가입 없이 자동으로 연동·로그인할 수 있는 SSO(Single Sign-On) 연동 방법을 안내합니다.
Step 1. 연동 신청
SSO 연동을 사용하려면 먼저 파트너 등록이 필요합니다. 아래 정보를 준비하신 후 FINGERPUSH.LINK에 회원가입하고 콘솔의 Q&A 문의를 통해 요청해 주세요.
| 항목 | 필수 | 설명 | 예시 |
|---|---|---|---|
| 서비스명 | ✅ | FINGERPUSH.LINK 관리자 화면에 표시될 서비스 이름 | ACME Corp |
| 허용 Origins | ✅ | SSO 요청을 허용할 도메인. 미등록 시 SSO 전체 차단됩니다. 복수 도메인은 콤마(,)로 구분. 로컬 테스트 시 http://localhost:포트도 포함하세요. | https://www.acme.com |
| SSO 로그인 URL | ✅ | FINGERPUSH.LINK 오류 발생 시 리다이렉트할 파트너 로그인 페이지 URL. https://로 시작해야 합니다. | https://www.acme.com/login?redirect=fplink |
| 담당자 이메일 | ✅ | 발급된 자격증명(client_id, shared_secret)을 수신할 이메일 주소 | dev@acme.com |
신청이 완료되면 아래 두 가지 정보가 담당자 이메일로 자동 발송됩니다.
| 발급 항목 | 설명 |
|---|---|
| client_id | 파트너 식별 키. JWT의 client_id claim에 포함해야 합니다. |
| shared_secret | JWT 서명용 비밀 키 (HMAC-SHA256). 서버가 자동 생성하여 담당자 이메일로 전송됩니다. 절대 외부에 노출하지 마세요. |
Step 2. JWT 생성 및 리다이렉트 연동
파트너 서버에서 JWT를 생성하고, 브라우저를 FINGERPUSH.LINK SSO 엔드포인트로 리다이렉트합니다.
JWT 토큰 구조 (파트너 서버에서 생성)
파트너 서버에서 아래 claims를 포함한 JWT를 생성합니다.
| claim | 필수 | 설명 | 예시 |
|---|---|---|---|
| sub | ✅ | 파트너 서비스의 사용자 고유 ID. 이후 계정 연동 키로 사용됨 | user_123 |
| ✅ | 사용자 이메일. 기존 FINGERPUSH.LINK 계정과 연동 또는 신규 가입에 사용 | user@example.com | |
| jti | ✅ | JWT 고유 ID. 파트너 서버가 요청마다 새로 생성. 동일 값 재사용 시 Replay Attack으로 간주해 차단됨 | 550e8400-e29b-41d4-a716-446655440000 |
| exp | ✅ | 만료 시각 (Unix timestamp). 발급 후 5분 이내 | 1716700800 |
| name | - | 사용자 표시 이름. 신규 계정 생성 시 기본값으로 사용 | 홍길동 |
| client_id | - | 등록된 파트너 client_id. 포함 시 요청 client_id와 일치 여부 추가 검증 | acme_prod |
JWT 생성 예시 (서버 사이드)
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
import java.util.UUID;
String jwt = Jwts.builder()
.setSubject("user_123") // 파트너 서비스 사용자 고유 ID
.claim("email", "user@example.com")
.claim("name", "홍길동")
.setId(UUID.randomUUID().toString()) // jti: 매 요청마다 새 UUID 필수
.setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000)) // 5분
.signWith(SignatureAlgorithm.HS256, sharedSecret.getBytes())
.compact();
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const token = jwt.sign(
{
sub: 'user_123', // 파트너 서비스 사용자 고유 ID
email: 'user@example.com',
name: '홍길동',
jti: uuidv4(), // 매 요청마다 새 UUID 필수
},
sharedSecret,
{ algorithm: 'HS256', expiresIn: '5m' }
);
SSO 엔드포인트
GET https://console.fingerpush.link/sso/callback?token={JWT}&client_id={client_id}
구현 예시 (서버 사이드 리다이렉트)
// JWT 생성 후 SSO 엔드포인트로 리다이렉트
String redirectUrl = "https://console.fingerpush.link/sso/callback"
+ "?token=" + URLEncoder.encode(token, "UTF-8")
+ "&client_id=" + URLEncoder.encode(clientId, "UTF-8");
response.sendRedirect(redirectUrl);
Step 3. 연동 흐름 이해
FINGERPUSH.LINK는 아래 순서로 처리합니다.
- JWT 서명 검증 (shared_secret 기반 HMAC-SHA256)
- jti 중복 사용 체크 (Replay Attack 방지)
- sub 값으로 기존 연동 계정 조회 → 있으면 바로 로그인
- 이메일로 기존 FINGERPUSH.LINK 계정 조회 → 있으면 이메일 인증 후 계정 연동
- 신규 사용자: 약관 동의 화면 → 이메일 인증 → 계정 생성
| 케이스 | 동작 |
|---|---|
| 이미 연동된 사용자 | 바로 로그인 처리 후 콘솔 이동 |
| 이메일이 같은 기존 계정 존재 | 해당 이메일로 인증 링크 발송 → 클릭 후 계정 연동 (보안 이유로 인증 필요) |
| 완전 신규 사용자 | 약관 동의 화면 → 가입 후 이메일 인증 → 계정 활성화 |
URL/Campaign 빠른 연동 샘플
아래 샘플은 API 키를 브라우저에 노출하지 않고 서버 세션에 저장해 사용하는 방식입니다. SSO 동의 완료 후 서버가 Service API를 호출하는 구조를 권장합니다.
| 구분 | 금지 패턴 | 권장 패턴 |
|---|---|---|
| API 키 보관 | 브라우저 localStorage/세션스토리지/전역 JS 변수 저장 | 서버 세션 또는 서버 보안 저장소에만 저장 |
| API 호출 경로 | 브라우저가 Service API를 직접 호출하며 X-API-KEY 포함 | 브라우저는 파트너 서버만 호출, 파트너 서버가 Service API 호출 |
| 보안 사고 영향 | 브라우저 디버거/XSS/확장프로그램으로 키 유출 위험 | 키 미노출로 유출면 축소, 서버 측 감사/차단 정책 적용 가능 |
적용 순서 (복사 전 먼저 확인)
- link.jsp를 SSO 진입 URL에 배치하고 redirect_uri, client_id 검증을 설정합니다.
- 기존 로그인 성공 로직(예: loginAction.jsp) 마지막에 login-success-hook.jsp 코드를 삽입합니다.
- redirect.jsp를 배치하고 JWT 생성 + /sso/callback 리다이렉트를 연결합니다.
- URL/Campaign 기능이 필요하면 shorturl-sample.jsp 패턴으로 서버 프록시 엔드포인트를 추가합니다.
- 운영 전 shared_secret과 허용 도메인(redirect_uri host) 설정을 재확인합니다.
파일별 역할
| 파일 | 역할 | 언제 호출됨 |
|---|---|---|
| link.jsp | SSO 파라미터 저장 + 로그인 페이지 진입 제어 | 사용자가 "핑거푸시 계정으로 로그인" 버튼 클릭 시 |
| login-success-hook.jsp | 로그인 성공 후 SSO 요청인지 판별해 redirect.jsp로 전달 | 파트너 사이트 로그인 성공 직후 |
| redirect.jsp | JWT 생성 후 FINGERPUSH.LINK SSO 콜백으로 리다이렉트 | 이미 로그인 사용자 또는 로그인 직후 |
| shorturl-sample.jsp | 세션 API 키로 URL/Campaign Service API 프록시 호출 | 연동 완료 후 URL 단축/캠페인 생성 시 |
검증 체크리스트
- 미로그인 상태에서 SSO 진입 시 로그인 페이지로 이동한다.
- 로그인 성공 후 /sso/callback으로 자동 이동한다.
- 잘못된 redirect_uri 또는 client_id 요청이 차단된다.
- 브라우저 Network 탭에 X-API-KEY가 노출되지 않는다.
- URL 단축/캠페인 생성 응답이 파트너 서버를 통해 정상 반환된다.
1) URL 단축 샘플 (서버 프록시)
// 세션에서 API 키 조회
String apiKey = (String) session.getAttribute("FPLINK_API_KEY");
if (apiKey == null || apiKey.isBlank()) {
response.setStatus(401);
out.print("{\"code\":\"NOT_CONNECTED\",\"error\":\"연동이 필요합니다.\"}");
return;
}
// 파트너 서버 → Service API 호출
// POST /api/v1/service/urls
conn.setRequestProperty("X-API-KEY", apiKey);
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
String jsonBody = "{\"url\":\"" + escapedUrl + "\"}";
// 응답의 shortUrl/shortCode를 그대로 프론트에 전달
// 브라우저는 파트너 서버만 호출 (API 키 미노출)
const formData = new URLSearchParams();
formData.append('originalUrl', originalUrl);
const res = await fetch('/samples/sso/fingerpush-shorturl-sample.jsp', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString()
});
const data = await res.json();
2) Campaign 생성 샘플 (서버 프록시)
// 세션 API 키로 캠페인 생성
String apiKey = (String) session.getAttribute("FPLINK_API_KEY");
if (apiKey == null || apiKey.isBlank()) {
response.setStatus(401);
out.print("{\"code\":\"NOT_CONNECTED\"}");
return;
}
// POST /api/v1/service/campaigns
conn.setRequestProperty("X-API-KEY", apiKey);
String body = "{" +
"\"name\":\"" + escapedName + "\"," +
"\"originalUrl\":\"" + escapedUrl + "\"," +
"\"utmSource\":\"fingerpush\"," +
"\"utmMedium\":\"push\"" +
"}";
// alias/id를 프론트에 전달해 /c/{alias} URL 표시
const formData = new URLSearchParams();
formData.append('action', 'campaign');
formData.append('campaignName', campaignName);
formData.append('campaignUrl', campaignUrl);
formData.append('utmSource', 'fingerpush');
formData.append('utmMedium', 'push');
const res = await fetch('/samples/sso/fingerpush-shorturl-sample.jsp', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formData.toString()
});
const data = await res.json();
JSP 복사본 샘플 (매뉴얼에서 바로 사용)
아래 4개 코드는 파일로 저장 후 바로 사용할 수 있는 최소 동작 버전입니다. 경로/도메인/시크릿 값은 운영 환경에 맞게 교체하세요.
1) SSO 리다이렉트 샘플 (redirect.jsp)
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" import="javax.crypto.Mac,javax.crypto.spec.SecretKeySpec,java.util.Base64,java.nio.charset.StandardCharsets,java.time.Instant,java.util.UUID" %>
<%
String fpUserId = "user_123";
String userEmail = "user@example.com";
String userName = "홍길동";
String clientId = "fingerpush_dev";
String sharedSecret = System.getenv("FPLINK_SHARED_SECRET");
if (sharedSecret == null || sharedSecret.isBlank()) {
response.sendError(500, "sharedSecret 누락");
return;
}
long now = Instant.now().getEpochSecond();
long exp = now + 300;
String jti = UUID.randomUUID().toString();
String header = Base64.getUrlEncoder().withoutPadding()
.encodeToString("{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8));
String payloadJson = "{"
+ "\"jti\":\"" + jti + "\","
+ "\"sub\":\"" + fpUserId + "\","
+ "\"email\":\"" + userEmail + "\","
+ "\"name\":\"" + userName + "\","
+ "\"client_id\":\"" + clientId + "\","
+ "\"iat\":" + now + ","
+ "\"exp\":" + exp
+ "}";
String payload = Base64.getUrlEncoder().withoutPadding()
.encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8));
String signingInput = header + "." + payload;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(sharedSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = Base64.getUrlEncoder().withoutPadding()
.encodeToString(mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8)));
String token = signingInput + "." + signature;
String consoleUrl = "https://fpl-stage-console.kissoft.biz";
String redirectUrl = consoleUrl + "/sso/callback?token="
+ java.net.URLEncoder.encode(token, "UTF-8")
+ "&client_id=" + java.net.URLEncoder.encode(clientId, "UTF-8");
response.sendRedirect(redirectUrl);
%>
2) SSO 링크 진입 샘플 (link.jsp)
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%
String redirectUri = request.getParameter("redirect_uri");
String clientId = request.getParameter("client_id");
if (redirectUri == null || redirectUri.isBlank()) {
response.sendError(400, "redirect_uri 필요");
return;
}
if (clientId == null || !clientId.matches("[a-zA-Z0-9_]{1,50}")) {
response.sendError(400, "client_id 형식 오류");
return;
}
String host = new java.net.URL(redirectUri).getHost();
if (!host.equals("console.fingerpush.link")
&& !host.equals("fpl-stage-console.kissoft.biz")
&& !host.equals("fpl.fingerpush.link")) {
response.sendError(403, "허용되지 않은 redirect_uri");
return;
}
session.setAttribute("SSO_REDIRECT_URI", redirectUri);
session.setAttribute("SSO_CLIENT_ID", clientId);
Object loggedInUser = session.getAttribute("LOGIN_MEMBER");
if (loggedInUser != null) {
request.getRequestDispatcher("/sso/redirect.jsp").forward(request, response);
return;
}
response.sendRedirect(request.getContextPath() + "/memberNew.jsp?mode=login");
%>
3) 로그인 성공 후크 샘플 (login-success-hook.jsp)
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%
String ssoRedirectUri = (String) session.getAttribute("SSO_REDIRECT_URI");
if (ssoRedirectUri != null && !ssoRedirectUri.isEmpty()) {
session.removeAttribute("SSO_REDIRECT_URI");
session.removeAttribute("SSO_CLIENT_ID");
request.getRequestDispatcher("/sso/redirect.jsp").forward(request, response);
return;
}
// SSO 요청이 아니면 기존 로그인 완료 흐름 유지
// response.sendRedirect("/index.jsp");
%>
4) URL/Campaign 통합 샘플 (shorturl-sample.jsp 최소본)
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" import="java.net.*,java.io.*,java.nio.charset.StandardCharsets" %>
<%
if ("POST".equalsIgnoreCase(request.getMethod())) {
response.setContentType("application/json; charset=UTF-8");
String action = request.getParameter("action");
if ("saveApiKey".equals(action)) {
String apiKey = request.getParameter("apiKey");
if (apiKey == null || apiKey.isBlank()) { response.setStatus(400); out.print("{\"error\":\"API 키 누락\"}"); return; }
session.setAttribute("FPLINK_API_KEY", apiKey.trim());
out.print("{\"success\":true}");
return;
}
String apiKey = (String) session.getAttribute("FPLINK_API_KEY");
if (apiKey == null || apiKey.isBlank()) { response.setStatus(401); out.print("{\"code\":\"NOT_CONNECTED\"}"); return; }
String apiBase = "https://fpl-stage-console.kissoft.biz";
String apiPath = "campaign".equals(action) ? "/api/v1/service/campaigns" : "/api/v1/service/urls";
String body = "campaign".equals(action)
? ("{\"name\":\"" + request.getParameter("campaignName") + "\",\"originalUrl\":\"" + request.getParameter("campaignUrl") + "\"}")
: ("{\"url\":\"" + request.getParameter("originalUrl") + "\"}");
HttpURLConnection conn = (HttpURLConnection) new URL(apiBase + apiPath).openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
conn.setRequestProperty("X-API-KEY", apiKey);
conn.setDoOutput(true);
try (OutputStream os = conn.getOutputStream()) { os.write(body.getBytes(StandardCharsets.UTF_8)); }
InputStream is = conn.getResponseCode() >= 400 ? conn.getErrorStream() : conn.getInputStream();
StringBuilder sb = new StringBuilder();
if (is != null) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
String line; while ((line = br.readLine()) != null) sb.append(line);
}
}
response.setStatus(conn.getResponseCode());
out.print(sb.toString());
return;
}
%>
<!doctype html>
<html lang="ko"><body>
<h3>SSO + API키 기반 URL/Campaign 샘플</h3>
<input id="apiKey" placeholder="동의 후 받은 API 키" />
<button onclick="saveApiKey()">API 키 저장</button>
<hr/>
<input id="url" placeholder="https://example.com" />
<button onclick="shorten()">URL 단축</button>
<hr/>
<input id="campName" placeholder="캠페인명" />
<input id="campUrl" placeholder="캠페인 URL" />
<button onclick="campaign()">Campaign 생성</button>
<pre id="out"></pre>
<script>
async function saveApiKey(){
const fd = new URLSearchParams();
fd.append('action','saveApiKey');
fd.append('apiKey', document.getElementById('apiKey').value);
const r = await fetch(location.pathname,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:fd.toString()});
document.getElementById('out').textContent = await r.text();
}
async function shorten(){
const fd = new URLSearchParams();
fd.append('originalUrl', document.getElementById('url').value);
const r = await fetch(location.pathname,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:fd.toString()});
document.getElementById('out').textContent = await r.text();
}
async function campaign(){
const fd = new URLSearchParams();
fd.append('action','campaign');
fd.append('campaignName', document.getElementById('campName').value);
fd.append('campaignUrl', document.getElementById('campUrl').value);
const r = await fetch(location.pathname,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:fd.toString()});
document.getElementById('out').textContent = await r.text();
}
</script>
</body></html>
보안 주의사항
- JWT는 서버에서만 생성: shared_secret을 클라이언트에 절대 노출하지 마세요.
- 만료 시간 5분 이내: exp claim을 반드시 설정하세요.
- jti 고유값: 각 요청마다 새로운 UUID를 사용하세요. 동일 jti 재사용 시 400 오류가 반환됩니다.
- HTTPS 필수: SSO 엔드포인트는 반드시 HTTPS로 호출하세요.
- Origin 검증: 파트너 등록 시 입력한 도메인에서만 SSO 요청이 허용됩니다.
오류 코드
| 오류 메시지 | 원인 | 해결 방법 |
|---|---|---|
| 등록되지 않은 파트너이거나 SSO 설정이 완료되지 않았습니다. | client_id가 없거나 shared_secret 미설정 | 파트너 등록 및 secret 설정 확인 |
| SSO 토큰이 만료되었습니다. | exp 초과 | 서버에서 JWT 재생성 (최대 5분) |
| 이미 사용된 SSO 토큰입니다. | jti 재사용 | 요청마다 새 UUID로 jti 생성 |
| SSO 토큰 서명이 유효하지 않습니다. | shared_secret 불일치 | 발급받은 secret과 동일한 값 사용 확인 |
| JWT client_id가 일치하지 않습니다. | JWT 내 client_id claim과 요청 파라미터 불일치 | JWT claim과 POST 파라미터를 동일하게 설정 |
| 이메일 정보가 없어 회원가입을 진행할 수 없습니다. | 신규 사용자인데 email claim 누락 | JWT에 이메일 포함 필요 |
테스트 방법
로컬 개발 환경 테스트
FAQ
Q. 이미 FINGERPUSH.LINK 계정이 있는 사용자는 어떻게 되나요?
JWT의 email과 일치하는 기존 계정이 있으면, 해당 이메일로 계정 연동 인증 메일이 발송됩니다. 사용자가 메일의 링크를 클릭하면 두 계정이 연동되고, 이후부터는 SSO로 바로 로그인됩니다.
Q. shared_secret을 변경하면 기존 사용자에게 영향이 있나요?
이미 연동 완료된 기존 사용자에게는 영향이 없습니다. 변경 후 생성되는 새 JWT부터 새 secret을 사용하면 됩니다.
Q. 사용자가 파트너 서비스 계정을 탈퇴하면 어떻게 되나요?
FINGERPUSH.LINK 계정은 독립적으로 유지됩니다. 필요시 담당자에게 연동 해제를 요청하거나, FINGERPUSH.LINK 콘솔 설정에서 직접 계정을 삭제할 수 있습니다.
Q. 한 사용자가 여러 파트너 서비스와 연동할 수 있나요?
네. 하나의 FINGERPUSH.LINK 계정에 여러 파트너 서비스를 동시에 연동하는 것은 지원되지 않습니다. 파트너 서비스 1개당 FINGERPUSH.LINK 계정 1개가 연결됩니다.