테마
첫 로그인 완료 폼 (First-time Signup)
logi(1pass) IdP는 sub + email (+ nickname) 정도의 신원만 RP로 전달합니다. 그런데 대부분의 RP는 자체 회원가입 시 추가 필드(역할, 조직, 도메인, 약관 동의 등)를 요구합니다. 이 페이지는 첫 로그인 시 그 정보를 1회만 받는 권장 패턴을 정리합니다.
자동 가입은 위험합니다
"콜백 핸들러에서 알아서 User.create!" 패턴은 무권한 사용자에게 임의 권한이 부여되는 취약점으로 이어지기 쉽습니다. 실제 사례: krx_listing이 role: :reviewer default로 자동 가입시키다가 1pass 인증만 통과하면 누구나 reviewer 권한을 얻는 결함이 있었습니다.
자동 가입을 굳이 쓰려면 반드시 가장 제한적인 권한(role: :guest 같은)으로 만들고, 운영자가 검토 후 승급하는 흐름을 보장해야 합니다.
권장 흐름
mermaid
sequenceDiagram
autonumber
participant U as 브라우저
participant RP as RP (Rails/Inertia)
participant L as logi
U->>RP: GET /login → "1pass 로그인" 클릭
RP->>L: GET /oauth/authorize (PKCE)
L->>U: /session/new (QR 또는 Apple/Google)
U->>L: 인증
L->>RP: 302 /auth/1pass/callback?code&state
RP->>L: POST /oauth/token (code → tokens)
RP->>L: GET /oauth/userinfo
L-->>RP: {sub, email, nickname}
alt sub 매칭되는 user 있음
RP->>U: redirect / (sign_in)
else email 매칭되는 user 있음 (case B)
RP->>RP: User.update sso_provider/subject 연결
RP->>U: redirect / (sign_in)
else 신규 사용자
RP->>RP: session[:one_pass_pending] = {sub,email,name,expires_at,tokens}
RP->>U: redirect /auth/1pass/complete_signup
U->>RP: GET /auth/1pass/complete_signup<br>(이메일/이름 read-only + 필요 필드 선택)
U->>RP: POST /auth/1pass/complete_signup
alt role=낮은권한 (예: issuer)
RP->>RP: User.create!(account_status: :active, sso_provider:, sso_subject:, role:, ...)
RP->>U: redirect / (sign_in)
else role=격상권한 (예: reviewer)
RP->>RP: User.create!(account_status: :pending, ...)
RP->>U: redirect /login (관리자 승인 대기 안내)
Note over RP: admin이 /admin/users에서 활성화하기 전까지 sign_in 차단
end
end또한 known-sub로 다시 SSO 로그인 시도하는 사용자도 active_for_authentication? 체크를 통과해야 sign_in — pending/blocked는 토큰/세션 사이드 이펙트 없이 거절합니다.
핵심 원칙
- 권한 자기선택 ≠ 안전 — 사용자가 self-select할 수 있는 role은 반드시 가장 낮은 권한만 두세요. 격상된 role(reviewer/admin/oncall 등)은 (a) 즉시 활성화하지 않고
account_status: pending으로 만들어 admin 승인을 거치거나, (b) email/도메인/초대 코드 allowlist를 통과해야만 신청 가능하도록 게이팅하세요. krx_listing은 (a) 패턴을 적용 중입니다. - Pending TTL은 짧게 — 권장 10분. logi의 OAuth state TTL과 동일하게 맞추면 일관됨.
- 토큰은 완료 후 저장 — pending 상태에서는
session[:one_pass_pending]안에만 토큰을 보관. 사용자가 가입을 완료해User가 만들어진 다음에야session[:one_pass_access_token]같은 정상 키로 옮긴다. - email match 시 기존 sso_subject 덮어쓰기 금지 — 이미 다른 1pass 계정과 연결된 이메일이면 가입 거부 + 안내. 사용자 혼동 방지 + 계정 탈취 가드.
- Replay/double-submit 가드 —
complete_signupPOST 진입 시(sso_provider, sso_subject)또는email로 다시 한 번 확인. callback과 form submit 사이에 다른 경로로 가입됐을 수 있음. - device_poll(QR) 분기 동일 — 콜백뿐 아니라 RFC 8628 device flow의 poll에서도 동일하게 신규 사용자를 완료 폼으로 보낸다. 한쪽만 막으면 우회 가능.
- 권한 화이트리스트는 한 곳에 —
ALLOWED_ROLES,ALLOWED_DOMAINS같은 화이트리스트는 컨트롤러 concern으로 빼서 일반 가입(/register)과 SSO 완료 폼이 같은 값을 쓰도록.
구현 체크리스트 (RP 통합 시)
- [ ] 콜백에서
find_or_create_user-스타일 자동 생성 코드를 제거하고, 못 찾으면nil반환 - [ ]
nil인 경우 pending 세션 저장 + 완료 폼 페이지로 redirect - [ ] 완료 폼 페이지에서 받을 RP-특이 필드 정의 (보통 role, 조직, domain, 약관 동의)
- [ ] 격상된 role(reviewer/admin 등)은
account_status: pending으로 생성 + sign_in 건너뛰고 "관리자 승인 대기" 화면으로 - [ ] device flow / QR poll 분기에도 같은 처리 추가
- [ ] expired/missing pending 시
/login리다이렉트 + flash - [ ] email 매칭 시 기존 sso_subject 덮어쓰기 금지 가드
- [ ] 완료 후 pending 세션 클리어 (
session.delete(:one_pass_pending)) - [ ] (선택) 완료 폼 진입 횟수 제한 또는 rate limit
- [ ] 일반 회원가입 폼(
/register)에도 같은 격상-역할 게이팅 적용 — UI 통로가 다르더라도 정책은 동일하게
Rails 8 예시 (krx_listing 발췌)
ruby
# app/controllers/concerns/signup_attribute_whitelist.rb
module SignupAttributeWhitelist
ALLOWED_ROLES = %w[issuer reviewer].freeze
ALLOWED_DOMAINS = %w[listing delisting both].freeze
private
def allowed_signup_role(value)
ALLOWED_ROLES.include?(value.to_s) ? value.to_s : nil
end
def allowed_signup_domain(value)
ALLOWED_DOMAINS.include?(value.to_s) ? value.to_s : nil
end
endruby
# app/controllers/web/one_pass_sso_controller.rb (요약)
class Web::OnePassSsoController < ApplicationController
include SignupAttributeWhitelist
PENDING_TTL = 10.minutes
def callback
# ... PKCE/state 검증, 토큰 교환, userinfo 조회는 기존 그대로 ...
user, link_error = find_or_link_existing_user(sub:, email:, userinfo:)
return fail_with(link_error) if link_error
if user.nil?
store_pending_signup!(sub:, email:, userinfo:, tokens:)
return redirect_to auth_1pass_complete_signup_path
end
sign_in(user)
persist_one_pass_tokens(tokens)
redirect_to root_path
end
def complete_signup_new
pending = fetch_valid_pending or
return redirect_to(login_path, alert: "1pass 인증 세션이 만료되었습니다.")
render inertia: "Auth/OnePassCompleteSignup",
props: { email: pending["email"], name: pending["name"],
allowed_roles: ALLOWED_ROLES, allowed_domains: ALLOWED_DOMAINS }
end
def complete_signup_create
pending = fetch_valid_pending or
return redirect_to(login_path, alert: "1pass 인증 세션이 만료되었습니다.")
role = allowed_signup_role(params[:role])
domain = allowed_signup_domain(params[:user_domain])
return redirect_to(auth_1pass_complete_signup_path,
inertia: { errors: { base: "역할/도메인 선택이 올바르지 않습니다." } },
status: :unprocessable_entity) if role.nil? || domain.nil?
sub = pending["sub"]
pending_email = pending["email"].to_s
tokens = pending["tokens"] || {}
# Replay 가드 ①: 같은 sub로 이미 가입됐다면 그쪽으로 sign_in.
if (existing = User.find_by(sso_provider: "1pass", sso_subject: sub))
clear_pending_signup!
sign_in(existing)
return redirect_to root_path
end
# Replay 가드 ②: callback 이후 다른 경로로 같은 이메일이 가입됐을 수 있음.
# 그런 경우 기존 사용자를 덮어쓰는 대신 거절 — 일반 로그인 후 link 시도하라고 안내.
if pending_email.present? && User.find_by(email: pending_email).present?
clear_pending_signup!
return redirect_to login_path,
alert: "이미 가입된 이메일입니다. 일반 로그인 후 1pass 연결을 시도해주세요."
end
# 격상된 role은 admin 승인 전까지 pending — 자기-격상 방지(원칙 0).
initial_status = (role == "reviewer") ? :pending : :active
user = User.create!(
email: pending_email.presence || "1pass-#{sub}@1pass.internal",
name: pending["name"].presence || "1pass user",
role: role,
user_domain: domain,
password: SecureRandom.hex(32),
account_status: initial_status,
sso_provider: "1pass",
sso_subject: sub
)
clear_pending_signup!
if user.account_status == "active"
sign_in(user)
persist_one_pass_tokens(tokens)
redirect_to root_path, notice: "1pass 계정으로 가입이 완료되었습니다."
else
# pending — admin이 /admin/users에서 활성화할 때까지 로그인 차단.
redirect_to login_path, notice: "가입 신청이 접수되었습니다. 관리자 승인 후 로그인 가능합니다."
end
end
private
def find_or_link_existing_user(sub:, email:, userinfo:)
user = User.find_by(sso_provider: "1pass", sso_subject: sub)
return [ user, nil ] if user
if email.present? && !email.end_with?("@1pass.internal") &&
(user = User.find_by(email: email))
# 다른 sso_subject 덮어쓰기 금지
return [ nil, "이 이메일에 다른 1pass 계정이 이미 연결되어 있습니다." ] if
user.sso_subject.present? && user.sso_subject != sub
user.update!(sso_provider: "1pass", sso_subject: sub) if user.sso_subject != sub
return [ user, nil ]
end
[ nil, nil ] # 신규 사용자 — 호출자가 완료 폼으로 보낼 것
end
def store_pending_signup!(sub:, email:, userinfo:, tokens:)
session[:one_pass_pending] = {
"sub" => sub, "email" => email.to_s,
"name" => (userinfo["nickname"].presence || userinfo["name"]).to_s,
"expires_at" => (Time.current + PENDING_TTL).to_i,
"tokens" => tokens.slice("access_token", "refresh_token", "expires_in")
}
end
def fetch_valid_pending
pending = session[:one_pass_pending]
return nil if pending.blank? || pending["expires_at"].to_i < Time.current.to_i
pending
end
def clear_pending_signup!; session.delete(:one_pass_pending); end
end보안 노트
- 세션 스토어: Rails 기본
cookie_store는secret_key_base로 서명·AES 암호화되므로 브라우저는 내용을 읽을 수도, 변조할 수도 없습니다. 다만 암호화된 blob 자체는 사용자 쿠키에 저장되어 클라이언트로 왕복합니다. 토큰까지 쿠키에 담는다면Secure,HttpOnly,SameSite=Lax|Strict+secret_key_base회전 정책을 명시적으로 운영하세요. 더 엄격한 통제가 필요하면ActionDispatch::Session::CacheStore(Redis 등) 같은 server-side store로 전환하세요. - CSRF:
complete_signup_create는 일반 POST이므로 Rails CSRF 토큰 체크가 자동 적용됩니다 (protect_from_forgery). Inertia 클라이언트는 자동으로 CSRF 헤더를 실어 보냅니다. - 세션 고정 (session fixation):
sign_in시점에reset_session후 필요한 키만 다시 심는 것이 표준 baseline입니다. 본 reference는 외부에서 설계된Authentication#sign_in을 호출하므로 별도로reset_session을 호출하지 않습니다 — 자체 RP에서는 로그인 경계에서 명시적으로 호출하는 걸 권장합니다. - 로깅: pending 진입 / 완료 / 만료를 모두 INFO 레벨로 남기되 sub/이메일은 마스킹하세요. (예:
mask(value)헬퍼로 앞 6자리 + 뒤 2자리만 노출). 디버깅 외 운영 환경에서는 raw값을 로그에 남기지 말 것.
다른 RP 적용 현황
| RP | 패턴 적용 상태 | 메모 |
|---|---|---|
| krx_listing | ✅ reference 구현 (이 문서의 모범) | role + user_domain |
| ainote | ⏭ 예정 | role(free/premium) + 외부 가입 정책 결정 후 |
| launchcrew | ⏭ 예정 | omniauth 기반, 약관 동의도 함께 받아야 함 |
| ax_admin | ⏭ 예정 | role + status, 공개 가입 vs 초대 코드 결정 필요 |