Dev Api
외부 자동화 도구가 AhaWiki 페이지를 직접 수정할 수 있도록 사용자 개인 API Key 인증을 추가했다.
세션 쿠키와 reCAPTCHA 없이 Authorization: Bearer <key> 헤더로 페이지를 읽고 저장할 수 있는 API를 제공한다.
페이지 히스토리에는 편집자가 기존 사용자로 기록되며, API Key를 통한 편집은 Page.viaApi = TRUE로 표시한다.
1. 설계 결정
- API Key는 기존 사용자 계정에 직접 연결한다. 자동화 전용 계정을 별도로 만들지 않는다.
- API Key로 저장한 revision은
Page.viaApi = TRUE로 기록한다. - 어느 key로 저장했는지는
Page.userApiKeynullable FK로 함께 기록한다. viaApi(불변 사실)와userApiKey(삭제/이름변경 가능한 엔티티 참조)는 서로 다른 질문에 답하므로 둘 다 유지한다.- 불변식:
userApiKey IS NOT NULL이면viaApi = TRUE다. 역은 성립하지 않는다(viaApi = TRUE && userApiKey = NULL은 "API로 저장됐지만 그 키는 이후 삭제됨"). - key를 hard delete해도
viaApi사실이 보존되도록ON DELETE SET NULL을 쓴다. 현재 key는 soft-delete(dateRevoked)만 하므로 평상시 FK는 끊기지 않는다. UserApiKey.name은 사람이 읽을 수 있는 키 이름이다. 화면/응답에는 join으로 현재 이름을 노출하고, key가 없으면 이름 없이viaApi만 표시한다.- API Key 원문은 DB에 저장하지 않고 SHA-256 hash만 저장한다. 원문 key는 생성 응답에서 한 번만 보여준다.
- AhaWiki API는 Bearer 인증을 사용하므로 CSRF token과 reCAPTCHA를 요구하지 않는다.
- AhaWiki API의 읽기/쓰기 권한은 기존
WikiPermission규칙과 key 소유자 사용자의 권한을 그대로 따른다. - API Key 관리는 사용자가 Account Settings에서 직접 한다. Admin은 전체 key 조회와 강제 폐기만 할 수 있다.
2. Data Model
2.1. UserApiKey
UserApiKey 테이블은 사용자별 API Key 메타데이터와 hash를 저장한다.
seq BIGINT AUTO_INCREMENT PRIMARY KEYuser INT NOT NULL—User.seqFKkeyHash VARCHAR(64) NOT NULL UNIQUE— SHA-256 hexkeyPrefix VARCHAR(32) NOT NULL— 목록에서 key를 식별하기 위한 prefixname VARCHAR(255) NOT NULL— 사람이 읽을 수 있는 키 이름 (이전label에서 rename)dateInserted DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMPdateLastUsed DATETIME NULLdateRevoked DATETIME NULLINDEX (user, dateRevoked)— 사용자별 key 목록 조회
keyHash UNIQUE가 인증 조회 인덱스 역할을 하므로 별도 INDEX (keyHash)는 만들지 않는다.
2.2. Page.viaApi
Page 테이블에 viaApi BOOLEAN NOT NULL DEFAULT FALSE 컬럼을 추가했다.
- 일반 웹 편집은 기본값
FALSE다. - API Key 저장만
TRUE로 기록한다. Page,PageWithoutContent, row parser, history 조회, insert SQL에 모두viaApi를 포함한다.PageLogic.insert는viaApi: Boolean = false기본값을 받고, API Key 저장 시true를 전달한다.
2.3. Page.userApiKey
Page 테이블에 userApiKey BIGINT NULL 컬럼과 Page_UserApiKey_seq_fk FOREIGN KEY (userApiKey) REFERENCES UserApiKey (seq) ON DELETE SET NULL을 추가했다.
- 웹 편집은
NULL이고, API Key 저장만 해당 key의seq를 기록한다. Page에는userApiKey: Option[Long]seq만 둔다.PageWithoutContent는 history 표시용으로 join한userApiKeyName: Option[String]도 갖는다.PageLogic.insert는userApiKey: Option[Long] = None파라미터를 받는다.ApiV1.savePage/renamePage는withApiUserAndKey로 인증 key를 받아userApiKey = Some(apiKey.seq)를 전달한다.- 키 이름은 그때그때
UserApiKey에서 조회한다 — 변경 목록은LEFT JOIN, 페이지 목록·메타·본문은UserApiKey.selectNamesBySeqs. 비정규화 스냅샷은 두지 않는다.
3. 인증과 권한
SessionLogic.getApiKeyUser(request)가 Authorization: Bearer <key> 헤더를 읽고 raw key를 SHA-256으로 hash한 뒤 UserApiKey에서 활성 key를 조회한다.
인증 성공 시:
dateLastUsed를 갱신한다.- key 소유자의
User.SessionUser를 만든다. - primary email을 함께 담아 기존 email 기반 permission과 호환한다.
getApiKeyWithUser는 인증 key(UserApiKey)와 SessionUser를 함께 반환하고, getApiKeyUser는 그중 사용자만 돌려준다.
저장/이름변경처럼 userApiKey를 기록해야 하는 endpoint는 withApiUserAndKey로 둘을 함께 받는다.
AhaWiki API에서는 RequestWrapper.forUser(user)로 인증 사용자를 ContextWikiPage와 WikiPermission에 전달한다.
이 처리가 없으면 인증은 성공해도 권한 계산이 익명 사용자 기준으로 동작할 수 있다.
4. Bearer 인증은 v1 경로에서만 동작한다
Authorization: Bearer <key> 를 읽는 코드는 SessionLogic.getApiKeyWithUser 하나뿐이고, 그것을 부르는 것은 ApiV1 뿐이다.
그 밖의 endpoint 는 어떤 key 를 붙여도 key 를 보지 않는다.
그런데 헤더를 그냥 무시하지도 않는다. CSRF filter 를 건너뛸 경로를 정하는 것은 app/Filters.scala 이고, 거기서 빠지는 경로는 CSRF 검사를 받는다.
Play 의 기본값 play.filters.csrf.header.protectHeaders 는 Cookie 나 Authorization 이 붙은 요청만 검사 대상으로 삼는데, 이 저장소는 conf/base.conf 에서 그 값을 바꾸지 않는다.
그래서 검사 대상이 아니던 요청에 Authorization 헤더를 붙이는 순간 CSRF token 이 필요해지고, token 이 없으면 403 이다.
이 403 은 key 가 틀렸다는 뜻이 아니다. key 는 읽히지도 않는다.
유효한 key 든 아무 문자열이든 결과가 같고, 헤더를 빼면 같은 요청이 그대로 성공한다.
응답 본문이 Play 의 기본 오류 페이지라 제목에 Unauthorized 가 찍히는 것도 헷갈리는 이유다 — 인증이 거절된 것이 아니다.
2026-09-19 에 https://aha00a.com 에서 확인한 결과:
요청 | Authorization | 결과 |
|---|---|---|
| 없음 |
|
| 아무 문자열 |
|
| 유효한 key |
|
| 없음 |
|
| 유효한 key |
|
상태 코드로 구분하면, AhaWiki API 는 key 가 없거나 잘못됐을 때 401 을 주고 권한이 없을 때 403 을 준다(위 «인증과 권한»).
AhaWiki API 밖에서 받는 403 은 둘 중 어느 쪽도 아니라서, key 를 새로 발급하거나 바꿔도 사라지지 않는다.
4.1. 예전 endpoint 를 부르는 법
- 스크립트에서는
Authorization헤더 없이 부른다./api/renderAhaMark/...같은 예전 endpoint 는 원래 그렇게 쓰는 것이다. - 브라우저는
Cookie를 늘 보내므로 헤더를 빼는 것만으로는 부족하다.GET /api/csrf로 token 을 받아Csrf-Token헤더에 담아야 한다 —AhaWiki.Kanban.js가 그렇게 한다. - 이
403응답에는Set-Cookie: PLAY_SESSION=; Max-Age=0이 붙는다. 같은 요청이200일 때는 붙지 않는다. 브라우저에서 부른 경우 세션 쿠키가 지워지므로, 증상이403하나로 끝나지 않는다.
4.2. 하지 않기로 한 것
protectHeaders에서Authorization을 빼지 않았다. 그러면403은 사라지지만 요청이 인증되지도 않는다 — 예전 endpoint 는 key 를 읽지 않으니 여전히 익명 요청이고, 대신 Play 의 기본 보호 범위만 좁아진다.- CSRF 우회 경로를 넓히지 않았다. 우회해도 되는 것은 Bearer 로 인증하는 endpoint 뿐이고, 나머지는 세션 쿠키로 인증하므로 CSRF 검사가 필요하다.
5. API와 UI
5.1. AhaWiki API
외부 사용 설명서는 Api에 둔다.
GET /api/v1/page/*nameEncodedPOST /api/v1/page/*nameEncodedGET /api/v1/pagesPOST /api/v1/pages/metadataGET /api/v1/searchGET /api/v1/changesPOST /api/v1/renameDELETE /api/v1/page/*nameEncoded
저장 API는 JSON body의 revision, text, comment, minorEdit를 사용한다.
revision이 최신과 다르면 409 Conflict를 반환한다.
API 저장은 PageLogic.insert(..., viaApi = true), cache invalidate, page calculation enqueue를 하고, 2026-09-25부터 page.updated 도 발행한다 — 그 페이지를 열어 둔 사람에게 웹 저장과 똑같이 새로고침 토스트가 뜬다(Dev WebSocket 발행하는 경로).
Telegram 알림은 보내지 않는다 — minorEdit와 viaApi 저장을 제외한다.
5.2. AhaWikiDoc Sync 지원
AhaWikiDoc sync를 위해 AhaWiki API를 보강했다.
- 페이지 목록/메타 API는
name,revision,dateTime,isMinorEdit,viaApi,userApiKeyName,size,contentHash를 반환한다. GET /api/v1/pages는 2026-09-10 까지userApiKeyName을 항상null로 줬다 — 목록 SQL 이userApiKey컬럼을 읽지 않아Page의 기본값None이 그대로 나갔고, 메타·본문 endpoint 만 이름을 답했다. 지금은 읽고,ApiV1Spec이 목록에서도 이름을 단언한다.- Batch metadata API는 여러 page name의
revision,dateTime, hash를 한 번에 조회한다. - 최근 변경 API는 API Key 인증으로 page prefix, timestamp, page별 revision 기준 필터를 지원한다.
afterRevision은 페이지별 revision이므로name으로 단일 페이지를 지정한 경우에만 허용한다.- 이름변경 API는 revision 확인 후 기존 페이지를 rename하고, 기존 이름에는
viaApi = trueredirect page를 만든다. - 삭제 API는 revision과
confirm: true를 요구하며, 웹 삭제와 같은 정책으로 첨부파일도 삭제 처리한다. - sync state는 서버에 저장하지 않고 local manifest에
lastSyncedAt, page별revision,dateTime,contentHash를 기록하는 방식을 권장한다. - 문서 rename sync는 delete+create가 아니라
POST /api/v1/rename으로 기존 page history를 보존한다.
5.3. Account Settings
Account Settings에 API Key 관리 섹션을 추가했다.
- 내 API Key 목록
- name 기반 key 생성
- 생성 직후 plain text key 1회 표시와 복사 버튼
- key 폐기 버튼
목록 조회에서는 plain text key를 반환하지 않고 keyPrefix만 보여준다.
이 화면은 Admin SPA가 아니라 위키 문서와 같은 껍데기를 쓴다. Account/settings.scala.html이 _base 레이아웃 안에서 wikiContent · limitWidth를 두르고, 표는 wikiTableSimple이다. Admin 화면(React · Mantine)과 다른 쪽을 고른 것이므로, 이 화면을 고칠 때 Admin 쪽 컴포넌트를 가져오면 톤이 어긋난다.
세션 기반 내부 API:
GET /api/account/ApiKeysPOST /api/account/ApiKeysDELETE /api/account/ApiKeys/:seq
이 API는 로그인 세션과 CSRF token이 필요하다.
POST 응답에만 plain text key를 포함하고, 이후 조회에서는 keyPrefix만 반환한다.
5.4. Admin UI
Admin SPA에 /Admin/ApiKeys 화면을 추가했다.
- 전체 API Key 목록
- user nickname, name, key prefix, 생성일, 마지막 사용일, 폐기 상태
- Admin 강제 폐기
세션 기반 내부 Admin API:
GET /api/Admin/ApiKeysDELETE /api/Admin/ApiKeys/:seq
Admin 권한과 CSRF token이 필요하다.
6. 변경 이력 표시
viaApi는 사용자가 자동화 편집을 구분할 수 있도록 여러 화면과 API에 노출한다.
Api.change응답에viaApi포함ApiAdminReport.adminRecentChanges응답에viaApi포함- Wiki history 화면에 minor edit, Via API 이모지 컬럼과
Show ViaApi필터 추가 RecentChanges매크로에Include via API edits토글과 minor edit, Via API 이모지 컬럼 추가/api/change에includeViaApi파라미터 추가- Admin Recent Changes 페이지에 viaApi 포함/제외 토글과 minor edit, Via API 이모지 컬럼 추가
- Admin Dashboard의 Recent Changes 요약 표에 minor edit, Via API 이모지 컬럼 추가
viaApi 편집은 어느 key였는지도 함께 보여준다. key 이름은 그때 UserApiKey 에서 조회한다(방법은 위 «Data Model» 의 Page.userApiKey). 폐기는 dateRevoked 를 채울 뿐 행을 지우지 않고 조회도 폐기 여부를 보지 않으므로, 폐기한 key 의 이름도 그대로 나온다. 이름이 빠지는 것은 행을 DB 에서 직접 지웠을 때뿐이다 — Page.userApiKey 의 FK 가 ON DELETE SET NULL 이라 viaApi 만 남는다.
Api.change,ApiAdminReport.adminRecentChanges,ApiV1.changes, 페이지 목록/메타(ApiV1.listPages/pageMetadata/getPage) 응답에userApiKeyName포함- Wiki history 화면의 Via API 컬럼에 key 이름을 함께 표시(있을 때)
RecentChanges매크로는 각 revision comment에[viaApi:<name>]prefix로 표시- Admin Recent Changes / Dashboard의 Via API 셀에 key 이름을 함께 표시(
makeFlagCell의detail인자)
7. Security
- API Key는
SecureRandom으로 32 bytes를 생성하고 Base64 URL-safe 문자열로 표시한다. 형식은ahawiki_<token>이다. - DB에는 raw key를 저장하지 않는다.
- 고엔트로피 API Key이므로 SHA-256 hash로 비교한다. bcrypt/Argon2 같은 slow hash는 요청마다 불필요한 지연을 만든다.
- AhaWiki API는 CSRF filter를 우회한다. 어느 경로가 우회하는지는
app/Filters.scala가 정하고, 그 밖의 경로에Authorization을 붙이면 어떻게 되는지는 위 «Bearer 인증은 v1 경로에서만 동작한다»에 있다. - 브라우저 세션 기반 Account/Admin API는 기존 CSRF 정책을 유지한다.
- 별도 API Key 단위 rate limit은 1차 구현에 포함하지 않았다. 전역 IP rate limit도 API 요청을 세지 않는다 —
IpRateLimiter.categorize가/w/밖의 경로를 사람 신호로 분류하므로 API 호출로는 차단되지 않는다.
8. Tests
ApiV1Spec- API Key hash 저장
- 유효 key 인증 성공
- 폐기 key와 존재하지 않는 key 인증 실패
- 읽기/쓰기 권한 403
- API 읽기
- API 저장과
viaApi = TRUE,userApiKeyseq 기록, 읽기 응답의userApiKeyName노출 - revision 충돌
409 Conflict - 페이지 목록/메타 조회
- 최근 변경의
since,includeMinorEdit,includeViaApi, invalidsince검증 afterRevision을 단일 페이지에만 허용하는 정책 검증- API 이름변경과 redirect 생성
- API 삭제와 첨부파일 삭제 표시
- Account API 생성/목록/폐기
ApiV1FilterSpec- 실제
Filters체인에서/api/v1/POST가 CSRF token 없이 Bearer 인증만으로 저장되는지 검증 UnitTestSuiteSpec- 테스트 schema 는
TestSchema가schema/schema.sql덤프에서 만든다(2026-08-10 부터).viaApi,userApiKey컬럼은 덤프를 갱신하면 따라온다 — 손으로 쓴Pageschema 는 이제 없다
9. 검증
관련 테스트:
sbt.bat "testOnly com.aha00a.controllers.ApiV1Spec"sbt.bat "testOnly com.aha00a.controllers.ApiV1FilterSpec"
10. See Also
10.2. Similar Pages
Similar pages by cosine similarity. Words after page name are term frequency.