Appearance
04 활용
로그인 구현(환경별 설정), 회원 연결, 앱, 세션·로그아웃, 계정 상태 변화, 로그인 버튼 — 시나리오별 구현 방법.
| 상태 | 확정 (전환 전용 화면·로그아웃 복귀 주소·연결 해제 통지는 예정) |
| 최종 확인 | 2026-09-23 |
로그인
라이브러리로 구현 (권장)
디스커버리 주소만 넣으면 엔드포인트·서명키·PKCE·토큰 검증을 라이브러리가 처리한다. 검증된 라이브러리를 쓴다 — 직접 구현한 토큰 검증은 조용히 뚫린다.
공통 설정값:
issuer / discovery : {호스트}/.well-known/openid-configuration ([02 환경과 주소](./02-사전-설정#환경과-주소))
client_id : 발급받은 값
client_secret : 발급받은 값 (서버 환경변수)
scope : openid email
redirect_uri : 등록한 값과 정확히 일치
PKCE : S256 (서버가 필수로 요구)
client auth : client_secret_post
clock skew : 60초Node.js — openid-client v6: 06 샘플 클라이언트 전체가 이것이다.
Java / Spring Boot 3 — Spring Security OAuth2 Client
yaml
# application.yml
spring:
security:
oauth2:
client:
registration:
moneybox:
provider: moneybox
client-id: ${MEMBERSHIP_CLIENT_ID}
client-secret: ${MEMBERSHIP_CLIENT_SECRET}
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/moneybox" # 이 값을 등록한다
scope: openid, email
provider:
moneybox:
issuer-uri: https://membership.m-box.com # 디스커버리를 자동으로 읽는다java
// PKCE 강제 — Spring Security 6.x 는 공개 클라이언트가 아니면 기본으로 켜지 않는다
@Bean
SecurityFilterChain security(HttpSecurity http, ClientRegistrationRepository repo) throws Exception {
var resolver = new DefaultOAuth2AuthorizationRequestResolver(repo, "/oauth2/authorization");
resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
http.oauth2Login(o -> o.authorizationEndpoint(a -> a.authorizationRequestResolver(resolver)));
return http.build();
}로그인 성공 후 OidcUser.getSubject()가 sub, getEmail()이 email(없으면 null)이다.
Next.js — Auth.js (NextAuth v5)
ts
// auth.ts
import NextAuth from 'next-auth'
export const { handlers, auth } = NextAuth({
providers: [{
id: 'moneybox',
name: '머니박스 ID',
type: 'oidc',
issuer: process.env.MEMBERSHIP_ISSUER, // https://membership.m-box.com
clientId: process.env.MEMBERSHIP_CLIENT_ID,
clientSecret: process.env.MEMBERSHIP_CLIENT_SECRET,
authorization: { params: { scope: 'openid email' } },
checks: ['pkce', 'state', 'nonce'],
client: { token_endpoint_auth_method: 'client_secret_post' },
profile: (p) => ({ id: p.sub, email: p.email ?? null }), // email 은 없을 수 있다
}],
callbacks: {
async signIn({ profile }) { /* profile.sub 로 회원 찾기/만들기 */ return true },
},
})
// 콜백 주소 {origin}/api/auth/callback/moneybox 를 redirect_uri 로 등록한다Python — Authlib
python
oauth.register(
name="moneybox",
server_metadata_url="https://membership.m-box.com/.well-known/openid-configuration",
client_id=os.environ["MEMBERSHIP_CLIENT_ID"],
client_secret=os.environ["MEMBERSHIP_CLIENT_SECRET"],
client_kwargs={"scope": "openid email", "code_challenge_method": "S256",
"token_endpoint_auth_method": "client_secret_post"},
).NET / PHP: Microsoft.AspNetCore.Authentication.OpenIdConnect(Authority = 호스트, UsePkce = true, ResponseType = "code") / jumbojett/openid-connect-php(setCodeChallengeMethod('S256'), addScope(['email'])).
라이브러리가 ID 토큰 검증(서명·iss·aud·exp·nonce)을 실제로 수행하는지 확인이 필요하다. 일부 라이브러리는 옵션으로 꺼져 있다.
직접 구현
03 REST API의 인가 코드 요청 → 토큰 요청 → ID 토큰 검증 순서다. 검증 없이 id_token을 디코드만 해서 쓰면 위조 토큰을 받아들이게 된다. JWT 검증 라이브러리(jose, jjwt, PyJWT 등)로 JWKS 기반 RS256 검증을 하되, 라이브러리를 쓸 수 없는 환경이면 아래 절차를 그대로 구현한다.
ID 토큰 검증 절차 (로그인 응답 기준)
1. id_token 을 '.' 로 나눈다: header.payload.signature (각각 base64url)
2. header.alg 가 "RS256" 인지 확인. header.kid 로 JWKS(jwks_uri)에서 키를 고른다
- 없으면 JWKS 를 다시 받는다(키 회전). 그래도 없으면 거부
3. 서명 검증: RSASSA-PKCS1-v1_5 / SHA-256 (RFC 8017 §8.2.2)
- JWKS 의 n, e 는 base64url 로 인코딩된 빅엔디언 정수
- m = signature^e mod n 을 바이트로 펴서 00 01 FF…FF 00 ‖ DigestInfo ‖ SHA256(header.payload) 와 비교
- DigestInfo(SHA-256) 프리픽스 = 3031300d060960864801650304020105000420 (hex)
4. payload 검증
- iss == 디스커버리의 issuer
- aud == 내 client_id (단일 문자열. azp 없음)
- exp + 60초 > now, iat ≤ now + 60초
- nonce == 인가 요청에 보낸 값 (로그인 응답에서만. 갱신 응답에서는 검사하지 않음)
5. at_hash 검증(선택): BASE64URL( SHA256(access_token) 의 앞 16바이트 ) == at_hash
6. 통과하면 sub 를 쓴다. email 은 없을 수 있다두 줄로 요약하면 verify_rs256(jwks[kid], header.payload, signature) 뒤에 assert iss, aud, exp(+60s), nonce. 표준 라이브러리만으로도 구현 가능하나(정수 거듭제곱 + SHA-256), 검증된 JWT 라이브러리가 있으면 그것을 쓴다.
로그인 이후 — 연동 서비스 세션
토큰을 받은 뒤 연동 서비스의 로그인 상태(세션 쿠키 또는 자체 JWT)를 발급한다. 멤버십 토큰은 다음 용도로만 쓴다.
| 토큰 | 용도 |
|---|---|
id_token | 검증 후 sub·email 추출. 저장하지 않아도 된다 |
access_token | 사용자 정보 조회용(10분). 프론트엔드에 내려보내지 않는다 |
refresh_token | offline_access를 요청한 경우만. 서버에만 보관 |
멤버십 토큰을 프론트엔드에 저장하지 않는다. 연동 서비스 앱·브라우저는 멤버십 토큰을 볼 이유가 없다.
SSO — 자동 로그인
멤버십 세션(브라우저 쿠키, 14일)이 살아 있으면 어느 연동 서비스든 인가 요청을 보내기만 하면 화면 없이 인가 코드가 돌아온다. 첫 연결이면 동의 화면만 뜬다. 연동 서비스가 따로 할 일은 없다.
멤버십에 장애가 나면 신규 로그인은 안 되지만 연동 서비스 세션은 유효하다. 연동 서비스 세션 수명을 너무 짧게 잡지 않는다.
재인증
송금·비밀번호 변경처럼 민감한 동작 전에 사용자를 다시 확인하려면 인가 요청에 prompt=login(또는 max_age=0)을 붙인다. 멤버십 세션이 있어도 비밀번호를 다시 묻고, 이때만 ID 토큰에 auth_time이 들어온다. 연동 서비스는 auth_time이 최근 N초 이내인지로 판단한다.
연결
준비 — 컬럼 추가
회원 테이블에 membership_sub(문자열, 고유, NULL 허용)를 추가한다. 기존 회원은 전부 NULL로 시작한다. sub는 35자다.
sql
ALTER TABLE member ADD COLUMN membership_sub VARCHAR(40) NULL; -- 현재 35자. 접두어 확장 여지로 40
CREATE UNIQUE INDEX ux_member_membership_sub ON member (membership_sub);회원 매칭 키는 sub만 쓴다. sub는 영구 불변이다. 사용자가 이메일을 바꿔도, 로그인 수단을 바꿔도 같다. 이메일은 연락·표시용이고 바뀔 수 있으며 없을 수도 있다(01).
회원 확인과 등록
sub 로 회원 조회
├─ 있음 → 그 회원으로 로그인 (연동 서비스 세션 발급). email 이 왔으면 갱신
└─ 없음 → 신규 회원 생성(membership_sub = sub, email 은 있으면 저장·없으면 NULL)
→ 연동 서비스 온보딩 (email 이 없고 필요하면 여기서 받는다)온보딩은 연동 서비스 것이다. 이름·연락처·본인인증·서비스 약관 등 서비스에 필요한 정보를 이 시점에 받는다. 멤버십은 로그인 수단과 멤버십 약관만 처리했다.
기존 회원 연결
연동 서비스에 이미 회원이 있다면, 그 회원을 멤버십 계정과 잇는 것은 회원 본인이 한 번 통과해야 한다. 멤버십은 연동 서비스 회원 DB를 모르고, 법적으로도 회원 명단을 넘겨받을 수 없다(타 법인 간 개인정보 제공).
경로 ① — 로그인한 상태에서 전환 (권장)
핵심은 마지막 줄이다. 누가 로그인돼 있는지 연동 서비스가 확실히 아는 상태에서 sub를 그 행에 적는다. 이메일 매칭이 필요 없다. 이 콜백은 일반 로그인 콜백과 다른 경로(예: /auth/membership/link)로 두어, "새 로그인"이 아니라 "현재 회원에 연결"로 처리하는 것이 안전하다 — 같은 경로를 쓰면 state에 용도를 담아 구분한다.
전환 진입은 지금은 login_hint=<이메일>만 보낸다 — 일반 로그인 화면에 이메일이 채워진다. 전환 전용 화면("레밋 계정을 머니박스 ID로 전환합니다", 새 비밀번호 설정 안내)은 prompt=create로 진입하게 될 예정이며 예정 현재 서버는 prompt=create를 invalid_request(unsupported prompt value)로 거부한다. 반영되면 07 변경 이력에 알리고, 그때 prompt=create를 추가하면 된다. 위 다이어그램의 prompt=create는 그 시점의 모습이다.
경로 ② — 로그아웃 상태에서 통합 로그인
sub로 회원을 못 찾았고, 넘어온 email이 기존 회원과 일치하는 경우:
| 연동 서비스 쪽 이메일 상태 | 처리 |
|---|---|
| 연동 서비스가 검증한 이메일 | 그 회원에 sub 저장 (자동 연결 가능) |
| 미검증 이메일 | 자동 연결 금지 — 남의 계정을 가로챌 수 있다. "기존 계정이 있으신가요?" → 기존 로그인으로 인증 후 연결 |
일치하는 회원 없음, 또는 email 부재 | 신규 회원 생성 |
카카오 이메일 ≠ 연동 서비스 가입 이메일인 경우가 실제로 흔하다. 경로 ①을 기본으로 권장한다.
전환 기간 정책
기존 회원이 있는 서비스는 한동안 두 버튼이 공존한다. 어디까지 열어둘지는 연동 서비스의 결정이지만, 권장은 B다.
| 자체 가입 | 자체 로그인 | 결과 | |
|---|---|---|---|
| A | 열림 | 열림 | 미연결 회원이 계속 늘어 전환이 끝나지 않는다 |
| B (권장) | 닫힘 | 열림 (기존 회원용, 눈에 덜 띄게) | 신규는 전부 멤버십으로. 미연결 회원 수가 오픈 시점에 고정되고 줄어들기만 한다 |
| C | 닫힘 | 닫힘 | 미연결 회원은 첫 로그인에 경로 ① 필수. 가장 빠르지만 이탈 위험 |
B로 시작해 미연결 회원이 충분히 줄면 C로 넘어간다. C에서는 membership_sub가 NULL인 회원이 기존 로그인하면 경로 ①을 바로 태운다. 연결이 끝난 회원의 비밀번호 등 자체 로그인 컬럼은 지워도 된다.
B 정책이면 연동 서비스 가입 버튼은 [머니박스 ID로 가입/로그인] 하나다. 신규 고객은 멤버십에서 계정을 만들고 돌아오며, 연동 서비스는 sub가 없으니 신규 회원을 만들고 온보딩한다.
금지 사항
email을 회원 매칭 키로 쓰기 — 바뀔 수 있고, 없을 수 있고, 미검증이면 탈취 경로- 멤버십
sub를 다른 회사와 대조하기 — 서비스마다 다르게 발급되므로 되지도 않고, 해서도 안 된다 - 회원 명단을 멤버십에 보내 미리 만들어 달라고 하기 — 불가. 회원 본인이 가입·동의해야 한다
앱
iOS · Android 앱은 웹과 세 가지가 다르다.
| 원칙 | 이유 |
|---|---|
시스템 브라우저로 열기 (iOS ASWebAuthenticationSession, Android Chrome Custom Tabs) | 카카오·구글이 WebView 로그인을 차단한다. 시스템 브라우저는 멤버십 세션 쿠키를 공유해 SSO가 된다. WebView는 금지 |
| 복귀는 https 딥링크 (Universal Link / App Link) | 커스텀 스킴(remit://)은 다른 앱이 등록해 인가 코드를 가로챌 수 있다. 서버도 등록을 거부한다 |
| 토큰 요청은 앱 → 자사 백엔드 → 멤버십 | 앱은 client_secret을 가질 수 없다 |
PKCE의 code_verifier는 앱이 만들고 앱이 보관한 뒤 백엔드에 넘긴다. 그래야 딥링크로 새어 나간 인가 코드만으로는 토큰을 못 받는다.
iOS (Swift)
swift
let verifier = PKCE.randomVerifier() // 43~128자
let challenge = PKCE.s256(verifier)
var url = URLComponents(string: "https://membership.m-box.com/oauth/authorize")!
url.queryItems = [
.init(name: "client_id", value: "remit-ios"),
.init(name: "redirect_uri", value: "https://www.remit.example/app/cb"),
.init(name: "response_type", value: "code"),
.init(name: "scope", value: "openid email"),
.init(name: "state", value: state), .init(name: "nonce", value: nonce),
.init(name: "code_challenge", value: challenge), .init(name: "code_challenge_method", value: "S256"),
]
// Universal Link 복귀: callbackURLScheme 은 https 딥링크에서 쓰지 않는다 — 대신 앱 델리게이트의
// continue userActivity 로 code·state 를 받는다.
let session = ASWebAuthenticationSession(url: url.url!, callback: .https(host: "www.remit.example", path: "/app/cb")) { url, error in
// url 의 code·state 를 자사 백엔드에 verifier 와 함께 전달 → 백엔드가 토큰 요청
}
session.presentationContextProvider = self
session.start()Android (Kotlin, AppAuth)
kotlin
val config = AuthorizationServiceConfiguration.fetchFromIssuer(Uri.parse("https://membership.m-box.com")) { cfg, _ ->
val req = AuthorizationRequest.Builder(cfg!!, "remit-android", ResponseTypeValues.CODE,
Uri.parse("https://www.remit.example/app/cb")) // App Link
.setScope("openid email").setNonce(nonce).setState(state)
.setCodeVerifier(verifier) // AppAuth 가 S256 challenge 생성
.build()
// Custom Tabs 로 열기
startActivityForResult(AuthorizationService(this).getAuthorizationRequestIntent(req), RC_AUTH)
}
// onActivityResult: AuthorizationResponse.fromIntent(data) 의 authorizationCode 와 verifier 를
// 자사 백엔드에 전달. performTokenRequest 는 쓰지 않는다(시크릿이 앱에 있어야 하므로).앱은 웹과 별도 클라이언트로 등록한다(remit-ios, remit-android). 같은 서비스이므로 sub는 웹과 같다. 딥링크 파일(apple-app-site-association, assetlinks.json)은 연동 서비스 도메인에 두는 것이므로 멤버십과 무관하다.
멤버십 화면이 웹이라 앱에서 카카오를 누르면 카카오톡 앱 점프가 아닌 카카오 웹 로그인으로 간다 소셜 예정. 카카오톡에 로그인돼 있으면 자동 진행된다.
로그아웃
| 방식 | 구현 | 결과 |
|---|---|---|
| 연동 서비스 로그아웃 | 연동 서비스 세션 삭제. 리프레시 토큰을 보관했다면 토큰 폐기 | 멤버십 세션 유지 → 다른 서비스 SSO 유지. 대부분 이걸로 충분 |
| 멤버십 로그아웃 | 브라우저를 /oauth/logout으로 보낸다(id_token_hint 포함) | 멤버십 세션 종료 → 모든 서비스에서 재로그인 필요 |
사용자에게 "모든 서비스에서 로그아웃" 선택지를 줄 때만 멤버십 로그아웃을 쓴다. 로그아웃 후 복귀 주소(post_logout_redirect_uri)는 등록 기능 반영 후 사용 가능하다 예정.
토큰 갱신
사용자가 없는 시간에 서버가 멤버십을 호출할 일(사용자 정보 재조회 등)이 있을 때만 offline_access를 요청한다. 로그인 자체에는 필요 없다.
- 인가 요청에
scope=openid email offline_access와prompt=consent를 함께 보낸다.prompt=consent가 없으면 표준 규정대로offline_access가 조용히 무시되어 리프레시 토큰이 오지 않는다(응답scope에서 확인 가능). prompt=consent는 이미 동의한 사용자에게도 동의 화면을 매번 다시 보인다. SSO 무화면 로그인이 깨지므로 모든 로그인에 붙이지 말고, 리프레시 토큰이 실제로 필요한 진입점(예: 백그라운드 연동을 켜는 설정 화면)에서만 쓴다.- 30일 유효, 사용할 때마다 새 토큰으로 교체된다. 옛 토큰은 즉시 무효 — 응답의 새 값을 반드시 저장한다. 두 서버가 같은 토큰으로 동시에 갱신하면 한쪽이
invalid_grant를 받으므로 갱신은 한 곳에서만 한다. - 서버에만 보관한다. 토큰 갱신 참고.
계정 상태 변화
| 사용자에게 일어난 일 | 연동 서비스가 보게 되는 것 | 할 일 |
|---|---|---|
| 이메일 변경 | 다음 로그인의 email이 다름. sub 동일 | email 갱신 |
| 멤버십 계정 정지 | 로그인 시도: 안내 후 error=access_denied / 토큰 갱신: invalid_grant | 재로그인 유도 |
| 멤버십 계정 탈퇴 | 토큰 갱신: invalid_grant. 재가입하면 새 sub(새 회원으로 보임) | 연결 해제 처리. 옛 회원 정리는 연동 서비스 정책 |
| 연동 서비스 연결 해제(동의 철회) 예정 | 토큰 갱신: invalid_grant. 다음 로그인 시 동의 화면 재노출 후 같은 sub | 갱신 실패 시 재로그인 유도 |
invalid_grant를 받으면 리프레시 토큰을 버리고 그 회원을 연동 서비스 정책대로 처리한다. 회원 삭제 여부는 연동 서비스가 결정한다. 실시간 통지(웹훅)는 1차에 없다.
로그인 버튼
사용자가 "다른 계정으로 들어가는 것"임을 한눈에 알게 하는 것이 목적이다.
| 항목 | 규칙 |
|---|---|
| 문구 | 머니박스 ID로 로그인 (로그인·가입 겸용 버튼이면 머니박스 ID로 시작하기). 전환 배너는 머니박스 ID로 전환하기 |
| 로고 | 머니박스 심볼을 문구 왼쪽에. 파일은 클라이언트 발급 시 제공(SVG·PNG) |
| 크기 | 다른 소셜 로그인 버튼과 같은 높이. 최소 높이 44px, 로고 최소 20px |
| 색 | 제공된 심볼 색을 바꾸지 않는다. 버튼 배경은 연동 서비스 스타일에 맞춘다 |
| 위치 | 자체 로그인 병행 기간에는 자체 로그인보다 위 또는 같은 열. 신규 고객은 이 버튼으로만 들어온다(전환 정책 B) |
| 금지 | 문구 변경("통합 로그인" 등), 로고 변형, 다른 소셜 로그인 아이콘과 합성 |
보안 점검
| 항목 | 확인 |
|---|---|
state | 콜백에서 보낸 값과 다르면 중단하는가. 1회용인가 |
nonce | ID 토큰의 값과 비교하는가 |
| ID 토큰 검증 | 서명·iss·aud·exp를 실제로 확인하는가(라이브러리 옵션 확인) |
iss 파라미터 | 콜백의 iss를 디스커버리 issuer와 대조하는가(라이브러리가 하면 확인만) |
| 시크릿 | 서버 환경변수·시크릿 저장소에만 있는가 |
| 토큰 저장 | 멤버십 토큰이 브라우저·앱 저장소에 없는가 |
| 인가 코드 | 콜백 URL이 로그·리퍼러로 새지 않는가(콜백 처리 후 즉시 리다이렉트) |
email 부재 | email 없는 ID 토큰으로도 회원 생성·로그인이 되는가 |