로그인 없는 「본인 모드」 — 비밀 URL 인증은 1:1 이 아니라 1:N 이다
쿠키에 키 대신 다이제스트를 넣으면 「전부 끊기」 가 공짜가 된다. 그리고 로그에 키가 찍혔던 이야기
혼자 쓰는 Rails 앱에 회원가입 없이 「주인인지」 만 가리는 방법이다. 다 읽으면 비밀 URL + 서명 쿠키를 붙이는 법과, 이 방식이 왜 1:N 이 되는지, 언제 그걸로 충분하고 언제 로그인으로 가야 하는지 판단할 수 있다.
로그인을 붙이기 싫었다
이주 카운트다운(app151)은 나 혼자 쓰는 체크리스트다. 진행 상태는 git 저장소의 JSONL 에 쌓는다. 아무나 쓰면 안 되니 주인인지는 가려야 하는데, 회원가입 · 로그인 · 비밀번호 재설정까지 붙이는 건 과했다.
그래서 이렇게 했다. /owner?owner_key=<비밀키> 에 한 번 들어가면 서버가 credentials 의 키와 secure_compare 한다. 틀리면 404 다. 경로가 있다는 것도 안 알려 준다. 맞으면 서명 쿠키를 남기고, 이후 쓰기 요청은 그 쿠키를 보고 통과시킨다. 아니면 403.
1:1 이 아니다
처음 들으면 기기마다 토큰을 발급해 1:1 로 묶는 방식처럼 보인다. 아니다. 키는 앱 전체에 하나다. 폰이든 노트북이든 같은 키로 들어온다. 서버는 누구에게 쿠키를 줬는지 기록하지 않는다. 세션 테이블이 없다.
쿠키 안에는 SHA256("owner-mode:" + 키) 만 들어 있다. 그래서 모든 브라우저의 쿠키 값이 똑같다.
여기서 성질이 바로 나온다. 키만 바꾸면 모든 쿠키가 「옛 키의 다이제스트」 가 되어 한 번에 다 끊긴다. 대신 폰 하나만 끊는 건 못 한다. 기기를 구분할 정보가 서버에도 쿠키에도 없으니까. 누가 바꿨는지도 모른다. 모두 같은 「주인」 이다.
다이제스트 한 줄이 설계의 전부다
쿠키에 키 자체를 넣으면 쿠키가 곧 키라서, 쿠키가 새면 키가 샌다. 고정값 true 를 넣으면 키를 바꿔도 예전 쿠키가 살아 있어서 「전부 끊기」 가 안 된다. 다이제스트는 둘 다 막는다. 서명 쿠키라서 브라우저에서 값을 지어 넣어도 통과 못 한다.
로그에 키가 찍혀 있었다
처음엔 /owner/:key 처럼 경로로 받았다. 그러고 log/development.log 를 열어 보니 Started GET "/owner/_rPX... 로 키가 통째로 찍혀 있었다. 운영이었으면 로그 수집 서비스까지 흘러갔을 거다.
고친 방법이 재밌다. 경로가 아니라 쿼리로 받고, 이름을 owner_key 로 했다. 그러자 Started GET "/owner?owner_key=[FILTERED]" 가 됐다.
Rails 의 Started 줄은 request.filtered_path 를 찍는데, 이게 쿼리 문자열은 거르고 경로는 안 거른다. actionpack 8.0.4 소스에서도 path 는 그대로 두고 쿼리만 filtered_query_string 으로 바꾼다. 그리고 filter_parameters 는 부분 일치라서, 기본값에 있는 :_key 가 owner_key 에 걸린다. 설정을 하나도 안 늘렸다. 반대로 :key 를 넣었다면 monkey, keyword 까지 다 가려졌을 거다.
그래도 브라우저 방문 기록과 주소창 자동완성에는 남는다. 그래서 이 방식은 로컬에서 혼자 쓸 때까지만 쓰고, 배포 전에는 입력 폼(POST)으로 바꾸기로 했다.
다른 방법과 견주면
HTTP Basic 인증도 서버 상태가 없고 비용이 0 이지만, 세션마다 팝업이 뜨고 로그아웃이 없어 폰에서 쓰기 괴롭다. Devise 는 기기별 세션을 다룰 수 있지만 users 테이블 · 화면 · 메일이 따라온다. 기기 하나만 끊고 싶다면 결국 「기기 목록」 이 어딘가에 있어야 한다. 매직 링크든, 기기별 토큰이든, Passkey 든 전부 그렇다. 서버 상태를 0 으로 두는 대가가 1:N 이다.
그러니까 조건은 이렇다. 혼자 쓰고, 로컬이거나 배포 전에 폼으로 바꾼다. 둘 이상이 쓰기 시작하면 로그인으로 간다. 방식별 비교표와 코드는 아래 상세에 있다.
사내 도구나 개인 도구에 「어디까지 인증을 붙일지」 고민하고 있다면 negabaro 에 상황을 적어 보내 줘도 된다.
「주인 구분」 방법 비교
| 방법 | 짝 | 서버 상태 | 기기 하나만 끊기 | 드는 것 |
|---|---|---|---|---|
| 비밀 URL + 서명 쿠키 | 키 1 : 브라우저 N | 없음 | 못 함 (전부만) | 거의 없음 |
| HTTP Basic | 비번 1 : N | 없음 | 못 함 | 없음. 팝업 · 로그아웃 없음 |
| Devise 로그인 | 계정 1 : 세션 N | users 테이블 | 구성하기 나름 | 테이블 · 화면 · 메일 |
| 매직 링크 | 링크 1 : 브라우저 1 | 토큰 저장 | 된다 | 메일 발송 · 만료 관리 |
| 기기별 토큰 | 토큰 1 : 기기 1 | 기기 목록 | 된다 | 목록 저장소 |
| Passkey | 키 1 : 기기 1 | 공개키 목록 | 된다 | 구현이 제일 무겁다 |
| Tailscale · Cloudflare Access | 앱 밖에서 | 앱엔 없음 | 된다 | 앱 코드 0, 인프라에 기댐 |
혼자 쓰는 로컬 도구면 첫 줄로 충분하다. 둘 이상이 쓰거나 공개 서버에 올리면 아래 줄로 내려간다.
concern 과 컨트롤러
module OwnerMode
COOKIE = :owner_mode
def self.digest(key) = Digest::SHA256.hexdigest("owner-mode:" + key)
private
def owner?
key = OwnerMode.key
key.present? &&
ActiveSupport::SecurityUtils.secure_compare(cookies.signed[COOKIE].to_s, OwnerMode.digest(key))
end
def require_owner
head :forbidden unless owner?
end
end
class OwnerSessionsController < ActionController::Base
include OwnerMode
def show # GET /owner?owner_key=...
return head(:not_found) unless OwnerMode.valid_key?(params[:owner_key])
cookies.signed.permanent[COOKIE] = { value: OwnerMode.digest(OwnerMode.key), httponly: true }
redirect_to "/"
end
end
로그가 이렇게 바뀌었다
# 경로로 받았을 때
Started GET "/owner/_rPX...(키 전체)"
# 쿼리 + owner_key 로 받았을 때
Started GET "/owner?owner_key=[FILTERED]"
Parameters: {"owner_key"=>"[FILTERED]"}
핵심 포인트
/owner?owner_key=… 로 한 번 들어오면 credentials 의 키와 secure_compare, 틀리면 404
맞으면 서명 쿠키에 SHA256(owner-mode:키) 를 permanent · httponly 로 남긴다
쓰기 액션은 before_action :require_owner 로 막는다 (아니면 403)
키는 경로가 아니라 쿼리로, 이름은 _key 로 끝나게 — 로그에서 [FILTERED] 로 가려진다
배포 전에는 입력 폼(POST)으로 바꾼다. 방문 기록에 URL 이 남기 때문