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:
24
.env.example
24
.env.example
@@ -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
1
.gitignore
vendored
@@ -2,7 +2,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
.venv/
|
.venv/
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -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`
|
||||||
|
|||||||
134
flask_app.py
134
flask_app.py
@@ -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)
|
||||||
|
|||||||
30
index.html
30
index.html
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
32
script.js
32
script.js
@@ -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 동기화(서버가 있을 때만)
|
||||||
|
|||||||
60
server.js
60
server.js
@@ -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"] }));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user