DB 기반 관리자 권한으로 전환

- ncue_user에 is_admin 추가 및 can_manage 호환 유지
- /api/auth/sync 및 관리 API를 DB is_admin 기반으로 변경
- index.html 폴백에서 관리자 이메일 하드코딩 제거
- .env로 관리자 부트스트랩(ADMIN_EMAILS) 지원

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-02-25 21:09:02 +09:00
parent 19c6814d2f
commit c21a7b3739
7 changed files with 190 additions and 114 deletions

View File

@@ -1,24 +0,0 @@
## Database
DB_HOST=ncue.net
DB_PORT=5432
DB_NAME=ncue
DB_USER=ncue
DB_PASSWORD=REPLACE_ME
TABLE=ncue_user
## Auth0 (server-side)
# Auth0 Domain (without https://)
AUTH0_DOMAIN=ncue.net
# Auth0 SPA Application Client ID
AUTH0_CLIENT_ID=g5RDfax7FZkKzXYvXOgku3Ll8CxuA4IM
# Google connection name (usually google-oauth2)
AUTH0_GOOGLE_CONNECTION=google-oauth2
# Admin emails (comma-separated)
ADMIN_EMAILS=dsyoon@ncue.net,dosangyoon@gmail.com,dosangyoon2@gmail.com,dosangyoon3@gmail.com
## Optional
# Server port
PORT=8000
# Optional: allow writing config via API (not required if using env)
CONFIG_TOKEN=

1
.gitignore vendored
View File

@@ -2,7 +2,6 @@
node_modules/ node_modules/
.env .env
.env.* .env.*
!.env.example
# Python # Python
.venv/ .venv/

View File

@@ -181,7 +181,8 @@ sudo journalctl -u ncue-flask -n 200 --no-pager
> 참고: `flask_app.py`는 DB가 일시적으로 죽어도 앱 임포트 단계에서 바로 죽지 않도록(DB pool lazy 생성) 개선되어, > 참고: `flask_app.py`는 DB가 일시적으로 죽어도 앱 임포트 단계에서 바로 죽지 않도록(DB pool lazy 생성) 개선되어,
> “백엔드 프로세스가 안 떠서 Apache가 503”인 케이스를 줄였습니다. > “백엔드 프로세스가 안 떠서 Apache가 503”인 케이스를 줄였습니다.
기본적으로 `.env``ADMIN_EMAILS`에 포함된 이메일은 `can_manage=true`자동 승격됩니다. 관리 권한은 DB 사용자 테이블(`ncue_user`)`is_admin=true`부여됩니다.
(`can_manage`는 호환을 위해 남겨두며, 서버는 `is_admin`을 기준으로 동작합니다.)
### (선택) 역프록시/분리 배포 ### (선택) 역프록시/분리 배포
@@ -189,10 +190,24 @@ sudo journalctl -u ncue-flask -n 200 --no-pager
- 정적은 별도 호스팅 + API만 Flask: `index.html``window.AUTH_CONFIG.apiBase`에 API 주소를 넣고, - 정적은 별도 호스팅 + API만 Flask: `index.html``window.AUTH_CONFIG.apiBase`에 API 주소를 넣고,
Flask에서는 `CORS_ORIGINS`로 허용 도메인을 지정하세요. Flask에서는 `CORS_ORIGINS`로 허용 도메인을 지정하세요.
최초 로그인 사용자는 DB에 저장되지만 `can_manage=false`입니다. 관리 권한을 주려면: 최초 로그인 사용자는 DB에 저장되며 기본값은 `is_admin=false`입니다. 관리자 승격 방법:
- (권장) 서버 `.env``ADMIN_EMAILS`를 설정하면, 해당 이메일 사용자가 로그인할 때 `/api/auth/sync`에서 자동으로
`is_admin=true`로 승격됩니다. (프론트로 노출되지 않음)
- API(서버 `.env``CONFIG_TOKEN` 설정 후):
```bash
curl -sS -X POST http://127.0.0.1:8023/api/admin/users/set_admin \
-H "Content-Type: application/json" \
-H "X-Config-Token: $CONFIG_TOKEN" \
-d '{"email":"me@example.com","isAdmin":true}'
```
- 또는 SQL로 직접:
```sql ```sql
update ncue_user set can_manage = true where email = 'me@example.com'; update ncue_user set is_admin = true, can_manage = true where email = 'me@example.com';
``` ```
## 로그인(관리 기능 잠금) ## 로그인(관리 기능 잠금)
@@ -209,7 +224,7 @@ update ncue_user set can_manage = true where email = 'me@example.com';
- `AUTH0_DOMAIN` - `AUTH0_DOMAIN`
- `AUTH0_CLIENT_ID` - `AUTH0_CLIENT_ID`
- `AUTH0_GOOGLE_CONNECTION` (예: `google-oauth2`) - `AUTH0_GOOGLE_CONNECTION` (예: `google-oauth2`)
- `ADMIN_EMAILS` (예: `dosangyoon@gmail.com,dsyoon@ncue.net`) - (선택) `CONFIG_TOKEN` (관리자 승격 API 보호용)
3. Auth0 Application 설정에서 아래 URL들을 등록 3. Auth0 Application 설정에서 아래 URL들을 등록
- Allowed Callback URLs: `https://ncue.net/``https://ncue.net` - Allowed Callback URLs: `https://ncue.net/``https://ncue.net`
- Allowed Logout URLs: `https://ncue.net/``https://ncue.net` - Allowed Logout URLs: `https://ncue.net/``https://ncue.net`

View File

@@ -56,8 +56,7 @@ DB_CONNECT_TIMEOUT = int(env("DB_CONNECT_TIMEOUT", "5") or "5")
TABLE = safe_ident(env("TABLE", "ncue_user") or "ncue_user") TABLE = safe_ident(env("TABLE", "ncue_user") or "ncue_user")
CONFIG_TABLE = "ncue_app_config" CONFIG_TABLE = "ncue_app_config"
CONFIG_TOKEN = env("CONFIG_TOKEN", "").strip() CONFIG_TOKEN = env("CONFIG_TOKEN", "").strip()
ADMIN_EMAILS = set(parse_email_csv(env("ADMIN_EMAILS", "")))
ADMIN_EMAILS = set(parse_email_csv(env("ADMIN_EMAILS", "dosangyoon@gmail.com,dsyoon@ncue.net")))
# Auth0 config via .env (preferred) # Auth0 config via .env (preferred)
AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").strip() AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").strip()
@@ -135,6 +134,7 @@ def ensure_user_table() -> None:
first_login_at timestamptz, first_login_at timestamptz,
last_login_at timestamptz, last_login_at timestamptz,
last_logout_at timestamptz, last_logout_at timestamptz,
is_admin boolean not null default false,
can_manage boolean not null default false, can_manage boolean not null default false,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
updated_at timestamptz not null default now() updated_at timestamptz not null default now()
@@ -144,6 +144,11 @@ def ensure_user_table() -> None:
db_exec(f"create index if not exists idx_{TABLE}_email on public.{TABLE} (email)") db_exec(f"create index if not exists idx_{TABLE}_email on public.{TABLE} (email)")
db_exec(f"alter table public.{TABLE} add column if not exists first_login_at timestamptz") db_exec(f"alter table public.{TABLE} add column if not exists first_login_at timestamptz")
db_exec(f"alter table public.{TABLE} add column if not exists last_logout_at timestamptz") db_exec(f"alter table public.{TABLE} add column if not exists last_logout_at timestamptz")
db_exec(f"alter table public.{TABLE} add column if not exists is_admin boolean not null default false")
# Backward-compat: previously can_manage was used as admin flag. Preserve existing admins.
db_exec(f"update public.{TABLE} set is_admin = true where can_manage = true and is_admin = false")
# Keep can_manage consistent with is_admin going forward.
db_exec(f"update public.{TABLE} set can_manage = is_admin where can_manage is distinct from is_admin")
def ensure_config_table() -> None: def ensure_config_table() -> None:
@@ -158,20 +163,20 @@ def ensure_config_table() -> None:
) )
def is_admin_email(email: str) -> bool:
e = str(email or "").strip().lower()
return e in ADMIN_EMAILS
def bearer_token() -> str: def bearer_token() -> str:
h = request.headers.get("Authorization", "") h = request.headers.get("Authorization", "")
m = re.match(r"^Bearer\s+(.+)$", h, flags=re.IGNORECASE) m = re.match(r"^Bearer\s+(.+)$", h, flags=re.IGNORECASE)
return m.group(1).strip() if m else "" return m.group(1).strip() if m else ""
def verify_admin_from_request() -> Tuple[bool, str]: def is_bootstrap_admin(email: Optional[str]) -> bool:
e = str(email or "").strip().lower()
return bool(e and e in ADMIN_EMAILS)
def verify_user_from_request() -> Tuple[Optional[str], str]:
""" """
Returns (is_admin, email_lowercase). Returns (sub, email_lowercase).
Uses the same headers as /api/auth/sync: Uses the same headers as /api/auth/sync:
- Authorization: Bearer <id_token> - Authorization: Bearer <id_token>
- X-Auth0-Issuer - X-Auth0-Issuer
@@ -179,16 +184,31 @@ def verify_admin_from_request() -> Tuple[bool, str]:
""" """
id_token = bearer_token() id_token = bearer_token()
if not id_token: if not id_token:
return (False, "") return (None, "")
issuer = str(request.headers.get("X-Auth0-Issuer", "")).strip() issuer = str(request.headers.get("X-Auth0-Issuer", "")).strip()
audience = str(request.headers.get("X-Auth0-ClientId", "")).strip() audience = str(request.headers.get("X-Auth0-ClientId", "")).strip()
if not issuer or not audience: if not issuer or not audience:
return (False, "") return (None, "")
payload = verify_id_token(id_token, issuer=issuer, audience=audience) payload = verify_id_token(id_token, issuer=issuer, audience=audience)
sub = str(payload.get("sub") or "").strip()
email = (str(payload.get("email")).strip().lower() if payload.get("email") else "") email = (str(payload.get("email")).strip().lower() if payload.get("email") else "")
return (bool(email and is_admin_email(email)), email) return (sub or None, email)
def is_request_admin() -> Tuple[bool, str]:
"""
Returns (is_admin, email_lowercase) using DB-stored flag.
"""
if not db_configured():
return (False, "")
ensure_user_table()
sub, email = verify_user_from_request()
if not sub:
return (False, email)
row = db_one(f"select is_admin from public.{TABLE} where sub = %s", (sub,))
return (bool(row and row[0] is True), email)
def safe_write_json(path: Path, data: Any) -> None: def safe_write_json(path: Path, data: Any) -> None:
@@ -388,16 +408,17 @@ def api_auth_sync() -> Response:
name = (str(payload.get("name")).strip() if payload.get("name") else None) name = (str(payload.get("name")).strip() if payload.get("name") else None)
picture = (str(payload.get("picture")).strip() if payload.get("picture") else None) picture = (str(payload.get("picture")).strip() if payload.get("picture") else None)
provider = sub.split("|", 1)[0] if "|" in sub else None provider = sub.split("|", 1)[0] if "|" in sub else None
admin = bool(email and is_admin_email(email))
if not sub: if not sub:
return jsonify({"ok": False, "error": "missing_sub"}), 400 return jsonify({"ok": False, "error": "missing_sub"}), 400
bootstrap_admin = bool(email and is_bootstrap_admin(email))
sql = f""" sql = f"""
insert into public.{TABLE} insert into public.{TABLE}
(sub, email, name, picture, provider, first_login_at, last_login_at, can_manage, updated_at) (sub, email, name, picture, provider, first_login_at, last_login_at, is_admin, can_manage, updated_at)
values values
(%s, %s, %s, %s, %s, now(), now(), %s, now()) (%s, %s, %s, %s, %s, now(), now(), %s, %s, now())
on conflict (sub) do update set on conflict (sub) do update set
email = excluded.email, email = excluded.email,
name = excluded.name, name = excluded.name,
@@ -405,24 +426,27 @@ def api_auth_sync() -> Response:
provider = excluded.provider, provider = excluded.provider,
first_login_at = coalesce(public.{TABLE}.first_login_at, excluded.first_login_at), first_login_at = coalesce(public.{TABLE}.first_login_at, excluded.first_login_at),
last_login_at = now(), last_login_at = now(),
can_manage = (public.{TABLE}.can_manage or %s), is_admin = (public.{TABLE}.is_admin or public.{TABLE}.can_manage or %s),
can_manage = (public.{TABLE}.is_admin or public.{TABLE}.can_manage or %s),
updated_at = now() updated_at = now()
returning can_manage, first_login_at, last_login_at, last_logout_at returning is_admin, can_manage, first_login_at, last_login_at, last_logout_at
""" """
row = db_one(sql, (sub, email, name, picture, provider, admin, admin)) row = db_one(sql, (sub, email, name, picture, provider, bootstrap_admin, bootstrap_admin, bootstrap_admin, bootstrap_admin))
can_manage = bool(row[0]) if row else False is_admin = bool(row[0]) if row else False
can_manage = bool(row[1]) if row else False
user = ( user = (
{ {
"is_admin": is_admin,
"can_manage": can_manage, "can_manage": can_manage,
"first_login_at": row[1], "first_login_at": row[2],
"last_login_at": row[2], "last_login_at": row[3],
"last_logout_at": row[3], "last_logout_at": row[4],
} }
if row if row
else None else None
) )
return jsonify({"ok": True, "canManage": can_manage, "user": user}) return jsonify({"ok": True, "canManage": is_admin, "user": user})
except Exception: except Exception:
return jsonify({"ok": False, "error": "verify_failed"}), 401 return jsonify({"ok": False, "error": "verify_failed"}), 401
@@ -471,7 +495,8 @@ def api_config_auth_get() -> Response:
"value": { "value": {
"auth0": {"domain": AUTH0_DOMAIN, "clientId": AUTH0_CLIENT_ID}, "auth0": {"domain": AUTH0_DOMAIN, "clientId": AUTH0_CLIENT_ID},
"connections": {"google": AUTH0_GOOGLE_CONNECTION}, "connections": {"google": AUTH0_GOOGLE_CONNECTION},
"adminEmails": sorted(list(ADMIN_EMAILS)), # Deprecated: admin is stored per-user in DB (ncue_user.is_admin)
"adminEmails": [],
}, },
"updated_at": None, "updated_at": None,
"source": "env", "source": "env",
@@ -525,7 +550,7 @@ def api_links_put() -> Response:
- {"links":[...]} - {"links":[...]}
""" """
try: try:
ok_admin, _email = verify_admin_from_request() ok_admin, _email = is_request_admin()
if not ok_admin: if not ok_admin:
return jsonify({"ok": False, "error": "forbidden"}), 403 return jsonify({"ok": False, "error": "forbidden"}), 403
@@ -585,6 +610,65 @@ def api_config_auth_post() -> Response:
return jsonify({"ok": False, "error": "server_error"}), 500 return jsonify({"ok": False, "error": "server_error"}), 500
@app.post("/api/admin/users/set_admin")
def api_admin_users_set_admin() -> Response:
"""
CONFIG_TOKEN-protected API to set per-user admin flag in DB.
Body:
- {"sub":"...", "isAdmin": true}
- or {"email":"user@example.com", "isAdmin": true}
"""
try:
if not db_configured():
return jsonify({"ok": False, "error": "db_not_configured"}), 500
ensure_user_table()
if not CONFIG_TOKEN:
return jsonify({"ok": False, "error": "config_token_not_set"}), 403
token = str(request.headers.get("X-Config-Token", "")).strip()
if token != CONFIG_TOKEN:
return jsonify({"ok": False, "error": "forbidden"}), 403
body = request.get_json(silent=True) or {}
sub = str(body.get("sub") or "").strip()
email = str(body.get("email") or "").strip().lower()
is_admin = body.get("isAdmin")
if not isinstance(is_admin, bool):
# accept legacy keys
is_admin = body.get("admin")
if not isinstance(is_admin, bool):
return jsonify({"ok": False, "error": "missing_isAdmin"}), 400
if not sub and not email:
return jsonify({"ok": False, "error": "missing_identifier"}), 400
if sub:
sql = f"""
update public.{TABLE}
set is_admin = %s,
can_manage = %s,
updated_at = now()
where sub = %s
returning sub, email, is_admin
"""
row = db_one(sql, (is_admin, is_admin, sub))
else:
sql = f"""
update public.{TABLE}
set is_admin = %s,
can_manage = %s,
updated_at = now()
where email = %s
returning sub, email, is_admin
"""
row = db_one(sql, (is_admin, is_admin, email))
if not row:
return jsonify({"ok": False, "error": "not_found"}), 404
return jsonify({"ok": True, "user": {"sub": row[0], "email": row[1], "is_admin": bool(row[2])}})
except Exception:
return jsonify({"ok": False, "error": "server_error"}), 500
if __name__ == "__main__": if __name__ == "__main__":
# Production should run behind a reverse proxy (nginx) or gunicorn. # Production should run behind a reverse proxy (nginx) or gunicorn.
app.run(host="0.0.0.0", port=PORT, debug=False) app.run(host="0.0.0.0", port=PORT, debug=False)

View File

@@ -435,18 +435,11 @@
]); ]);
let sessionEmail = ""; let sessionEmail = "";
function isAdminEmail(email) {
const cfg = getAuthConfig();
const admins = cfg && Array.isArray(cfg.adminEmails) ? cfg.adminEmails : [];
const e = String(email || "").trim().toLowerCase();
if (admins.length) return admins.includes(e);
// fallback default
return ["dsyoon@ncue.net", "dosangyoon@gmail.com", "dosangyoon2@gmail.com", "dosangyoon3@gmail.com"].includes(e);
}
function canAccessLink(link) { function canAccessLink(link) {
// Admin access is determined by server (DB: ncue_user.is_admin) via /api/auth/sync.
if (auth && auth.authorized) return true;
const id = String(link && link.id ? link.id : ""); const id = String(link && link.id ? link.id : "");
const email = String(sessionEmail || "").trim().toLowerCase(); const email = String(sessionEmail || "").trim().toLowerCase();
if (email && isAdminEmail(email)) return true;
if (email) return ACCESS_USER_IDS.has(id); if (email) return ACCESS_USER_IDS.has(id);
return ACCESS_ANON_IDS.has(id); return ACCESS_ANON_IDS.has(id);
} }
@@ -751,6 +744,7 @@
const auth = { const auth = {
client: null, client: null,
user: null, user: null,
authorized: false,
ready: false, ready: false,
}; };
@@ -771,7 +765,6 @@
const v = data.value; const v = data.value;
const auth0 = v.auth0 || {}; const auth0 = v.auth0 || {};
const connections = v.connections || {}; const connections = v.connections || {};
const adminEmails = Array.isArray(v.adminEmails) ? v.adminEmails : Array.isArray(v.allowedEmails) ? v.allowedEmails : [];
const domain = String(auth0.domain || "").trim(); const domain = String(auth0.domain || "").trim();
const clientId = String(auth0.clientId || "").trim(); const clientId = String(auth0.clientId || "").trim();
const google = String(connections.google || "").trim(); const google = String(connections.google || "").trim();
@@ -781,7 +774,6 @@
JSON.stringify({ JSON.stringify({
auth0: { domain, clientId }, auth0: { domain, clientId },
connections: { google }, connections: { google },
adminEmails,
}) })
); );
return true; return true;
@@ -791,7 +783,7 @@
} }
function applyManageLock() { function applyManageLock() {
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail); const canManage = Boolean(auth.user) && Boolean(auth.authorized);
if (el.btnAdd) el.btnAdd.disabled = !canManage; if (el.btnAdd) el.btnAdd.disabled = !canManage;
if (el.btnImport) el.btnImport.disabled = !canManage; if (el.btnImport) el.btnImport.disabled = !canManage;
// 요청: 로그인 전 내보내기 비활성화 // 요청: 로그인 전 내보내기 비활성화
@@ -919,14 +911,18 @@
const raw = claims && claims.__raw ? String(claims.__raw) : ""; const raw = claims && claims.__raw ? String(claims.__raw) : "";
if (raw) { if (raw) {
const cfg = getAuthConfig(); const cfg = getAuthConfig();
await fetch(apiUrl("/api/auth/sync"), { const r = await fetch(apiUrl("/api/auth/sync"), {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${raw}`, Authorization: `Bearer ${raw}`,
"X-Auth0-Issuer": `https://${cfg.auth0.domain}/`, "X-Auth0-Issuer": `https://${cfg.auth0.domain}/`,
"X-Auth0-ClientId": cfg.auth0.clientId, "X-Auth0-ClientId": cfg.auth0.clientId,
}, },
}).catch(() => {}); }).catch(() => null);
if (r && r.ok) {
const data = await r.json().catch(() => null);
if (data && data.ok) auth.authorized = Boolean(data.canManage);
}
} }
} catch { } catch {
// ignore // ignore
@@ -1036,9 +1032,9 @@
e.preventDefault(); e.preventDefault();
return; return;
} }
// block manage actions unless admin // block manage actions unless admin (server: ncue_user.is_admin)
const act0 = btn.getAttribute("data-act"); const act0 = btn.getAttribute("data-act");
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail); const canManage = Boolean(auth.user) && Boolean(auth.authorized);
if (!canManage && (act0 === "fav" || act0 === "edit" || act0 === "del")) { if (!canManage && (act0 === "fav" || act0 === "edit" || act0 === "del")) {
toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다."); toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다.");
return; return;
@@ -1066,7 +1062,7 @@
if (el.btnExport) el.btnExport.addEventListener("click", exportJson); if (el.btnExport) el.btnExport.addEventListener("click", exportJson);
if (el.btnImport) if (el.btnImport)
el.btnImport.addEventListener("click", () => { el.btnImport.addEventListener("click", () => {
const canManage = Boolean(auth.user) && isAdminEmail(sessionEmail); const canManage = Boolean(auth.user) && Boolean(auth.authorized);
if (!canManage) return toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다."); if (!canManage) return toast("관리 기능은 로그인(관리자 이메일) 후 사용 가능합니다.");
if (el.file) el.file.click(); if (el.file) el.file.click();
}); });

View File

@@ -71,29 +71,16 @@
"link-ncue-net", "link-ncue-net",
"dreamgirl-ncue-net", "dreamgirl-ncue-net",
]); ]);
const DEFAULT_ADMIN_EMAILS = new Set([
"dsyoon@ncue.net",
"dosangyoon@gmail.com",
"dosangyoon2@gmail.com",
"dosangyoon3@gmail.com",
]);
function getUserEmail() { function getUserEmail() {
const e = auth && auth.user && auth.user.email ? String(auth.user.email) : ""; const e = auth && auth.user && auth.user.email ? String(auth.user.email) : "";
return e.trim().toLowerCase(); return e.trim().toLowerCase();
} }
function isAdminEmail(email) {
const e = String(email || "").trim().toLowerCase();
const cfg = getAuthConfig();
const admins = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : [];
if (admins.length) return admins.includes(e);
return DEFAULT_ADMIN_EMAILS.has(e);
}
function canAccessLink(link) { function canAccessLink(link) {
// Admin (DB: ncue_user.is_admin) can access all links.
if (auth && auth.authorized) return true;
const email = getUserEmail(); const email = getUserEmail();
if (email && isAdminEmail(email)) return true;
const id = String(link && link.id ? link.id : ""); const id = String(link && link.id ? link.id : "");
if (email) return ACCESS_USER_IDS.has(id); if (email) return ACCESS_USER_IDS.has(id);
return ACCESS_ANON_IDS.has(id); return ACCESS_ANON_IDS.has(id);
@@ -896,15 +883,6 @@
} }
} }
function isManageAdminEmail(email) {
const cfg = getAuthConfig();
const admins = Array.isArray(cfg.adminEmails) ? cfg.adminEmails : [];
const e = String(email || "").trim().toLowerCase();
if (admins.length) return admins.includes(e);
// 안전한 기본값: 설정이 비어있으면 기본 관리자만 관리 가능
return DEFAULT_ADMIN_EMAILS.has(e);
}
function updateAuthUi() { function updateAuthUi() {
// 로그인 전에는 사용자 배지를 숨김(요청: "로그인 설정 필요" 영역 제거) // 로그인 전에는 사용자 배지를 숨김(요청: "로그인 설정 필요" 영역 제거)
if (!auth.user) { if (!auth.user) {
@@ -982,8 +960,7 @@
auth.mode = "enabled"; auth.mode = "enabled";
await manualHandleCallbackIfNeeded().catch(() => {}); await manualHandleCallbackIfNeeded().catch(() => {});
auth.user = await manualLoadUser(); auth.user = await manualLoadUser();
const email = auth.user && auth.user.email ? String(auth.user.email) : ""; auth.authorized = false;
auth.authorized = Boolean(auth.user) && isManageAdminEmail(email);
auth.serverCanManage = null; auth.serverCanManage = null;
const t = loadTokens(); const t = loadTokens();
if (auth.user && t && t.id_token) { if (auth.user && t && t.id_token) {
@@ -1037,8 +1014,7 @@
const isAuthed = await auth.client.isAuthenticated(); const isAuthed = await auth.client.isAuthenticated();
auth.user = isAuthed ? await auth.client.getUser() : null; auth.user = isAuthed ? await auth.client.getUser() : null;
const email = auth.user && auth.user.email ? auth.user.email : ""; auth.authorized = false;
auth.authorized = Boolean(auth.user) && isManageAdminEmail(email);
auth.serverCanManage = null; auth.serverCanManage = null;
// 로그인되었으면 서버에 사용자 upsert 및 can_manage 동기화(서버가 있을 때만) // 로그인되었으면 서버에 사용자 upsert 및 can_manage 동기화(서버가 있을 때만)

View File

@@ -45,7 +45,7 @@ const DB_PASSWORD = must("DB_PASSWORD");
const TABLE = safeIdent(env("TABLE", "ncue_user") || "ncue_user"); const TABLE = safeIdent(env("TABLE", "ncue_user") || "ncue_user");
const CONFIG_TABLE = "ncue_app_config"; const CONFIG_TABLE = "ncue_app_config";
const CONFIG_TOKEN = env("CONFIG_TOKEN", "").trim(); const CONFIG_TOKEN = env("CONFIG_TOKEN", "").trim();
const ADMIN_EMAILS = new Set(parseEmailCsv(env("ADMIN_EMAILS", "dosangyoon@gmail.com,dsyoon@ncue.net"))); const ADMIN_EMAILS = new Set(parseEmailCsv(env("ADMIN_EMAILS", "")));
// Auth0 config via .env (preferred) // Auth0 config via .env (preferred)
const AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").trim(); const AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").trim();
@@ -107,6 +107,7 @@ async function ensureUserTable() {
first_login_at timestamptz, first_login_at timestamptz,
last_login_at timestamptz, last_login_at timestamptz,
last_logout_at timestamptz, last_logout_at timestamptz,
is_admin boolean not null default false,
can_manage boolean not null default false, can_manage boolean not null default false,
created_at timestamptz not null default now(), created_at timestamptz not null default now(),
updated_at timestamptz not null default now() updated_at timestamptz not null default now()
@@ -115,6 +116,11 @@ async function ensureUserTable() {
await pool.query(`create index if not exists idx_${TABLE}_email on public.${TABLE} (email)`); await pool.query(`create index if not exists idx_${TABLE}_email on public.${TABLE} (email)`);
await pool.query(`alter table public.${TABLE} add column if not exists first_login_at timestamptz`); await pool.query(`alter table public.${TABLE} add column if not exists first_login_at timestamptz`);
await pool.query(`alter table public.${TABLE} add column if not exists last_logout_at timestamptz`); await pool.query(`alter table public.${TABLE} add column if not exists last_logout_at timestamptz`);
await pool.query(`alter table public.${TABLE} add column if not exists is_admin boolean not null default false`);
// Backward-compat: previously can_manage was used as admin flag. Preserve existing admins.
await pool.query(`update public.${TABLE} set is_admin = true where can_manage = true and is_admin = false`);
// Keep can_manage consistent with is_admin going forward.
await pool.query(`update public.${TABLE} set can_manage = is_admin where can_manage is distinct from is_admin`);
} }
async function ensureConfigTable() { async function ensureConfigTable() {
@@ -127,11 +133,6 @@ async function ensureConfigTable() {
`); `);
} }
function isAdminEmail(email) {
const e = String(email || "").trim().toLowerCase();
return ADMIN_EMAILS.has(e);
}
app.post("/api/auth/sync", async (req, res) => { app.post("/api/auth/sync", async (req, res) => {
try { try {
await ensureUserTable(); await ensureUserTable();
@@ -149,15 +150,16 @@ app.post("/api/auth/sync", async (req, res) => {
const name = payload.name ? String(payload.name).trim() : null; const name = payload.name ? String(payload.name).trim() : null;
const picture = payload.picture ? String(payload.picture).trim() : null; const picture = payload.picture ? String(payload.picture).trim() : null;
const provider = sub.includes("|") ? sub.split("|", 1)[0] : null; const provider = sub.includes("|") ? sub.split("|", 1)[0] : null;
const isAdmin = email ? isAdminEmail(email) : false;
if (!sub) return res.status(400).json({ ok: false, error: "missing_sub" }); if (!sub) return res.status(400).json({ ok: false, error: "missing_sub" });
const bootstrapAdmin = Boolean(email && ADMIN_EMAILS.has(email));
const q = ` const q = `
insert into public.${TABLE} insert into public.${TABLE}
(sub, email, name, picture, provider, first_login_at, last_login_at, can_manage, updated_at) (sub, email, name, picture, provider, first_login_at, last_login_at, is_admin, can_manage, updated_at)
values values
($1, $2, $3, $4, $5, now(), now(), $6, now()) ($1, $2, $3, $4, $5, now(), now(), $6, $6, now())
on conflict (sub) do update set on conflict (sub) do update set
email = excluded.email, email = excluded.email,
name = excluded.name, name = excluded.name,
@@ -165,14 +167,15 @@ app.post("/api/auth/sync", async (req, res) => {
provider = excluded.provider, provider = excluded.provider,
first_login_at = coalesce(public.${TABLE}.first_login_at, excluded.first_login_at), first_login_at = coalesce(public.${TABLE}.first_login_at, excluded.first_login_at),
last_login_at = now(), last_login_at = now(),
can_manage = (public.${TABLE}.can_manage or $6), is_admin = (public.${TABLE}.is_admin or public.${TABLE}.can_manage or $6),
can_manage = (public.${TABLE}.is_admin or public.${TABLE}.can_manage or $6),
updated_at = now() updated_at = now()
returning can_manage, first_login_at, last_login_at, last_logout_at returning is_admin, can_manage, first_login_at, last_login_at, last_logout_at
`; `;
const r = await pool.query(q, [sub, email, name, picture, provider, isAdmin]); const r = await pool.query(q, [sub, email, name, picture, provider, bootstrapAdmin]);
const canManage = Boolean(r.rows?.[0]?.can_manage); const isAdmin = Boolean(r.rows?.[0]?.is_admin);
res.json({ ok: true, canManage, user: r.rows?.[0] || null }); res.json({ ok: true, canManage: isAdmin, user: r.rows?.[0] || null });
} catch (e) { } catch (e) {
res.status(401).json({ ok: false, error: "verify_failed" }); res.status(401).json({ ok: false, error: "verify_failed" });
} }
@@ -216,7 +219,8 @@ app.get("/api/config/auth", async (_req, res) => {
value: { value: {
auth0: { domain: AUTH0_DOMAIN, clientId: AUTH0_CLIENT_ID }, auth0: { domain: AUTH0_DOMAIN, clientId: AUTH0_CLIENT_ID },
connections: { google: AUTH0_GOOGLE_CONNECTION }, connections: { google: AUTH0_GOOGLE_CONNECTION },
adminEmails: [...ADMIN_EMAILS], // Deprecated: admin is stored per-user in DB (ncue_user.is_admin)
adminEmails: [],
}, },
updated_at: null, updated_at: null,
source: "env", source: "env",
@@ -282,6 +286,32 @@ app.post("/api/config/auth", async (req, res) => {
} }
}); });
// CONFIG_TOKEN-protected: set per-user admin flag in DB
app.post("/api/admin/users/set_admin", async (req, res) => {
try {
await ensureUserTable();
if (!CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "config_token_not_set" });
const token = String(req.headers["x-config-token"] || "").trim();
if (token !== CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "forbidden" });
const body = req.body && typeof req.body === "object" ? req.body : {};
const sub = String(body.sub || "").trim();
const email = String(body.email || "").trim().toLowerCase();
const isAdmin = typeof body.isAdmin === "boolean" ? body.isAdmin : typeof body.admin === "boolean" ? body.admin : null;
if (typeof isAdmin !== "boolean") return res.status(400).json({ ok: false, error: "missing_isAdmin" });
if (!sub && !email) return res.status(400).json({ ok: false, error: "missing_identifier" });
const q = sub
? `update public.${TABLE} set is_admin = $1, can_manage = $1, updated_at = now() where sub = $2 returning sub, email, is_admin`
: `update public.${TABLE} set is_admin = $1, can_manage = $1, updated_at = now() where email = $2 returning sub, email, is_admin`;
const r = await pool.query(q, [isAdmin, sub ? sub : email]);
if (!r.rows?.length) return res.status(404).json({ ok: false, error: "not_found" });
res.json({ ok: true, user: r.rows[0] });
} catch {
res.status(500).json({ ok: false, error: "server_error" });
}
});
// Serve static site // Serve static site
app.use(express.static(__dirname, { extensions: ["html"] })); app.use(express.static(__dirname, { extensions: ["html"] }));