Native 2FA 로그인 API (v2)
EU 사용자가 P-256 디바이스 키로 로그인하는 v2 endpoint. Authorization: Bearer 가 담는 토큰 종류로 두 경로가 갈린다. Plan 209 로 app token 경로는 디바이스 키 서명이 아니라 email OTP + 공개키 upsert(keyless) 로 동작한다 — 가입 직후·새 기기·분실 진입을 eID 의존 없이 단일 경로로 해결한다. 재로그인(user access token)은 기존 키 서명 그대로(강화). 로그인 method 전환은 별도 login/native/link 로 일원화됐다.
| 경로 | 시점 | Authorization | 인증 factor | body 핵심 |
|---|---|---|---|---|
| 1. keyless 로그인 (가입 직후 / 새 기기 / 분실) | 가입 직후(REGISTERED), 또는 새 기기·키 분실 | app token | email OTP(type=LOGIN) + 공개키 upsert | email, emailVerificationId, devicePublicKeySpki |
| 2. 동일 기기 재로그인 | 서비스 활성화 후 같은 디바이스에서 재진입 | user access token | 디바이스 키 서명 | signature |
| 3. method 전환 (link) | 이미 로그인된 사용자가 login method 를 native 로 | user access token | email OTP step-up + 새 공개키 | devicePublicKeySpki, emailVerificationId |
서버는 complete endpoint 에서 Authorization Bearer payload 종류로 분기 — app token = keyless(공개키 upsert), user access token = 재로그인(서명).
- 인증 채널은
Authorization: Bearer한 줄. - deviceId·userId 는 token payload 에서.
- keyless(app token): 디바이스 키 서명 없이 email OTP(type=LOGIN) 로 이메일 소유를 입증하고, body 의
devicePublicKeySpki를 ACTIVE 키로 upsert(expireAllForDevice+registerActive)한다. 핸들러가 OTP 검증 후email로 userId 를 해소한다. 이미 eID 링크된 사용자가 keyless 를 시도하면 409/2231(AUTH_METHOD_CONFLICT) — eID 로 로그인한 뒤 3. link 로 전환해야 한다(exactly one auth method 불변). - 재로그인(user access token): token payload 로 신원이 확정되므로 OTP 불필요. server nonce 를 디바이스 비밀키로 서명한다.
- AppCheck verify 자체는 app token 으로 호출(verify-only, 세션토큰 미발급).
- 마스터 문서: domain endpoints.md (로그인).
1. keyless 로그인 (가입 직후 / 새 기기 / 분실)
Native 2FA 회원가입 은 이미 로그인 세션을 반환하므로 가입 직후에는 이 경로가 필수가 아니다. 본 keyless 경로는 주로 새 기기·키 분실 상황에서 access/refresh 를 재발급받는 데 쓴다. 디바이스 키 서명 없이 LOGIN OTP + 공개키 upsert 로 진입한다(eID 첫 로그인과 대칭). initialize(nonce) 단계가 없다 — app token 경로는 nonce 를 쓰지 않는다.
| # | API | Authorization | body 핵심 | 응답에서 추출할 값 |
|---|---|---|---|---|
| 1 | POST /v2/auth/email/verification-code | Bearer <appToken> | { "email", "type": "LOGIN" } | requestId |
| 2 | POST /v2/auth/email/verify | Bearer <appToken> | { "email", "code", "requestId", "type": "LOGIN" } | verificationId, lastLoginMethod |
| 3 | POST /v2/security/appcheck/verify | Bearer <appToken> | { "token", "platform" } | { verified: true } |
| 4 | POST /v2/auth/login/native/complete ⭐ | Bearer <appToken> | { "email", "emailVerificationId", "devicePublicKeySpki" } | LoginV2ResponseDto |
가입 직후 동일 기기면 가입 때 받은 app token 을 그대로 재사용한다. 새 기기는
POST /v2/auth/app/challenge·complete-challenge로 새 app token 을 먼저 발급받은 뒤 ①~④ 를 거친다. ①② 의type=LOGIN은 가입 때 받은 REGISTRATION OTP 와 별개 캐시이므로 새로 발급받아야 한다. ④ complete 의 핸들러는 OTP(emailVerificationId) 를 검증한 뒤devicePublicKeySpki를 ACTIVE 키로 upsert 한다. eID 와 달리 fbeta OAuth chain 이 없어 외부 redirect 없이 끝난다.
2. 동일 기기 재로그인 (user access token)
활성(SERVICE_STARTED) 사용자가 같은 디바이스에서 재로그인. Authorization 에 자기 user access token 을 제시하면 서버는 user access token 분기로 라우팅하고, userId·deviceId 는 token payload 에서 가져온다. 인증 자체는 디바이스 비밀키로 server nonce 서명 (body signature). 재로그인은 initialize(nonce) → 서명 → complete 2단계다.
| # | API | Authorization | body 핵심 | 응답에서 추출할 값 |
|---|---|---|---|---|
| 1 | POST /v2/security/appcheck/verify | Bearer <appToken> (AppCheck 자체는 app token 필요) | { "token", "platform" } | { verified: true } |
| 2 | POST /v2/auth/login/native/initialize ⭐ | Bearer <userAccessToken> | 없음 (body-less) | challenge |
| 3 | (클라이언트 내부) 디바이스 비밀키로 ②의 challenge 를 ES256 서명 | — | — | signature |
| 4 | POST /v2/auth/login/native/complete ⭐ | Bearer <userAccessToken> | { "signature" } | LoginV2ResponseDto |
클라이언트는 app token + user access token 둘 다 보관하고 있으며, AppCheck verify(①) 만 app token, 그 외 로그인 호출은 user access token.
3. method 전환 (login/native/link)
이미 로그인된 사용자가 login method 를 native 로 전환하는 경로 (eID link 와 대칭). JwtAuthGuard(user access token) + email OTP step-up + 새 공개키를 받아 제출 키를 ACTIVE 로 등록하고, 기존 eID 링크를 같은 트랜잭션에서 만료한다(배타성 "requested method wins"). 세션은 이미 보유하므로 ack-only 응답. clinical cohort 는 native 2FA 불가 → 403.
| # | API | Authorization | body 핵심 | 응답에서 추출할 값 |
|---|---|---|---|---|
| 1 | POST /v2/auth/email/verification-code | Bearer <userAccessToken> | { "email", "type": "LOGIN" } | requestId |
| 2 | POST /v2/auth/email/verify | Bearer <userAccessToken> | { "email", "code", "requestId", "type": "LOGIN" } | verificationId, lastLoginMethod |
| 3 | POST /v2/auth/login/native/link ⭐ | Bearer <userAccessToken> | { "devicePublicKeySpki", "emailVerificationId" } | { linked, method, linkedAt, expiredEidLinks } |
이 경로는 eID → native 로의 method 전환 전용이다. 키 분실 복구(같은 method 유지)는 1. keyless 로그인 을 쓴다 (단, eID 사용자는 keyless 가 409 이므로 link 로 전환).
본 endpoint Spec
LI. POST /v2/auth/login/native/initialize ⭐
재로그인(2.) 전용 — server nonce 를 발급한다. keyless(app token) 경로는 이 endpoint 를 호출하지 않는다(nonce 불요).
- Path:
POST /v2/auth/login/native/initialize - 인증: 서버 라우팅 — 재로그인은
Bearer <userAccessToken> - Rate Limit: 5/분
- 🔗 라이브 명세 (Swagger UI): dev
Request
| Header | Value |
|---|---|
Authorization | Bearer <userAccessToken> |
Content-Type | application/json |
body 없음. deviceId·userId 는 token payload 에서. body 를 보내면 ValidationPipe 가 unknown value 로 거부한다.
Response 200 OK
{
"challenge": "base64url-32B-server-nonce",
"expiresIn": 300
}
| 필드 | 설명 |
|---|---|
challenge | 디바이스 개인키로 ES256(P-256, SHA-256, IEEE-P1363) 서명해야 할 서버 nonce (32바이트 base64url). |
expiresIn | nonce TTL (초, 기본 300). 시한 내 complete 를 호출해야 한다. |
Errors
| HTTP | code | message | 발생 조건 |
|---|---|---|---|
| 401 | 1000 | Authentication required | Authorization 검증 실패 (Authorization 누락, 무효 토큰, 만료) |
| 401 | 1000 | Missing deviceId claim in access token payload | user access token payload 에 deviceId 누락 |
| 404 | 2210 | NATIVE2FA_KEY_UNAVAILABLE | (userId, deviceId) 에 대응하는 ACTIVE 디바이스 키 없음 |
| 429 | 1000 | ThrottlerException: Too Many Requests | Endpoint throttle 5/분 |
LC. POST /v2/auth/login/native/complete ⭐
- keyless·2. 재로그인 — 두 경로 모두 같은 endpoint. Authorization 토큰 종류로 body 가 갈린다.
- Path:
POST /v2/auth/login/native/complete - 인증: 서버 라우팅 (FlexibleAuthGuard — app token 또는 user access token)
- Rate Limit: 5/분 (prod 상시 적용; dev/stage/local은
THROTTLE_DISABLED=true설정 시에만 해제) - 🔗 라이브 명세 (Swagger UI): dev
Request
| Header | Value |
|---|---|
Authorization | 1. = Bearer <appToken>, 2. = Bearer <userAccessToken> |
Content-Type | application/json |
같은 endpoint 이지만 Authorization 토큰 종류에 따라 body 가 완전히 달라진다. keyless(1.)는 서명을 보내지 않고 OTP+공개키를, 재로그인(2.)은 서명만 보낸다.
| 필드 | 타입 | 1. app token (keyless) | 2. user access token (재로그인) | 설명 |
|---|---|---|---|---|
signature | string | 보내지 않음 | 필수 | LI 응답 challenge 를 디바이스 비밀키로 ES256 서명한 값 (IEEE-P1363, 64B → base64url). |
email | string | 필수 | 보내지 않음 | 사용자 이메일 (type=LOGIN OTP 로 검증한 값). userId 해소 키. |
emailVerificationId | string | 필수 | 보내지 않음 | type=LOGIN OTP verify 가 반환한 verificationId. |
devicePublicKeySpki | string | 필수 | 보내지 않음 | upsert 할 디바이스 공개키 SPKI DER base64url (P-256). OTP 검증 후 ACTIVE 로 등록. |
keyless(1.)는 app token 만으로는 사용자를 단정할 수 없어 LOGIN OTP 로 이메일 소유를 입증하고
devicePublicKeySpki를 upsert 한다(서명 없음). 재로그인(2.)은 token payload 의 userId/deviceId 로 신원이 확정되므로 OTP·공개키 동반이 불필요하고 server nonce 서명만 검증한다.
Body 예시 — 1. keyless (app token 분기)
{
"email": "user@example.com",
"emailVerificationId": "verif_uuid_from_login_email_verify",
"devicePublicKeySpki": "MFkwEwYHKoZIzj0CAQ...public-key-spki-base64url"
}
Body 예시 — 2. 재로그인 (user access token 분기)
{
"signature": "MEUCIQD...device-key-signature-base64url"
}
deviceId, userId 는 body 에 받지 않는다.
Response 200 OK — LoginV2ResponseDto
{
"user": {
"id": "user_uuid",
"email": "user@example.com"
},
"userCycle": {
"id": null,
"status": null
},
"tokens": [
{
"token": "eyJhbGciOi...",
"type": "ACCESS_TOKEN",
"expiresIn": 3600,
"issuedAt": 1730000000
},
{
"token": "eyJhbGciOi...",
"type": "REFRESH_TOKEN",
"expiresIn": 1209600,
"issuedAt": 1730000000
}
],
"roles": [],
"permissions": [],
"profile": {
"userName": "Erika Mustermann",
"language": "de-DE"
},
"agreements": []
}
cohort/region/sessionPolicy는 응답 body 에 포함되지 않는다 — 모든 EU 인증 응답(register·login, eID·native)은LoginV2ResponseDto(v1LoginResponseDto와 동일 형태) 로 통일돼 있다.
-
- (keyless, 가입 직후):
userCycle.status = null, access token payload 에uci없음 (REGISTERED). 다음 단계POST /v2/auth/user-cycle/activate가uci가 박힌 새 토큰 재발급.
- (keyless, 가입 직후):
-
- (재로그인):
userCycle.status = "ACTIVE", access token payload 에uci포함.
- (재로그인):
Errors
| HTTP | code | message | 발생 조건 |
|---|---|---|---|
| 400 | 1001 | VALIDATION_ERROR | body 필드 길이·형식 오류 또는 unknown 필드 포함 |
| 400 | 2008 | EMAIL_NOT_VERIFIED | 1. keyless 에서 LOGIN OTP email/emailVerificationId 누락·불일치·만료. 재로그인(2.)에서는 발생하지 않음 |
| 400 | 2213 | NATIVE2FA_CHALLENGE_INVALID | 2. 재로그인에서 server nonce 누락/만료 또는 서명 검증 실패 |
| 400 | 2215 | NATIVE2FA_PUBLIC_KEY_INVALID | 1. keyless devicePublicKeySpki SPKI 파싱 실패 또는 P-256 EC 키 아님 |
| 401 | 1000 | Authentication required | Authorization 검증 실패 |
| 404 | 2210 | NATIVE2FA_KEY_UNAVAILABLE | 2. 재로그인에서 (userId, deviceId) 에 대응하는 ACTIVE 디바이스 키 없음 |
| 409 | 2231 | AUTH_METHOD_CONFLICT | 1. keyless 시도 사용자가 이미 eID 링크 보유 — eID 로그인 후 link 로 전환 |
| 429 | 2214 | NATIVE2FA_ACCOUNT_LOCKED | 연속 서명 실패로 디바이스 2FA 일시 잠금 (약 30분, BSI O.Auth_7) |
| 429 | 1000 | ThrottlerException: Too Many Requests | Endpoint throttle 5/분 (prod 상시 적용) |
LK. POST /v2/auth/login/native/link ⭐
이미 로그인된 사용자가 login method 를 native 로 전환한다 (eID link 와 대칭).
- Path:
POST /v2/auth/login/native/link - 인증: JwtAuthGuard (user access token 전용)
- Rate Limit: 5/시간 (factor 변경은 강한 인증 후에만 — BSI O.Auth_7/11/14)
- 🔗 라이브 명세 (Swagger UI): dev
Request
| Header | Value |
|---|---|
Authorization | Bearer <userAccessToken> |
Content-Type | application/json |
{
"devicePublicKeySpki": "MFkwEwYHKoZIzj0CAQ...public-key-spki-base64url",
"emailVerificationId": "verif_uuid_from_login_email_verify"
}
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
devicePublicKeySpki | string | Yes | ACTIVE 로 등록할 새 디바이스 공개키 SPKI DER base64url (P-256). |
emailVerificationId | string | Yes | type=LOGIN OTP verify 가 반환한 verificationId — email OTP step-up(2FA factor 변경) 증명. |
Response 201 Created — ack-only
{
"linked": true,
"method": "native",
"linkedAt": 1733650000000,
"expiredEidLinks": 1
}
| 필드 | 설명 |
|---|---|
linked | 항상 true. |
method | 전환된 method — "native". |
linkedAt | 전환 시각 (epoch ms). |
expiredEidLinks | 이 전환으로 만료된 eID 링크 수 (없으면 0). |
Errors
| HTTP | code | message | 발생 조건 |
|---|---|---|---|
| 400 | 1001 | VALIDATION_ERROR | devicePublicKeySpki/emailVerificationId 형식·길이 위반 |
| 400 | 2215 | NATIVE2FA_PUBLIC_KEY_INVALID | 제출 SPKI 파싱 실패 또는 P-256 EC 키 아님 |
| 401 | 1000 | Authentication required / EMAIL_NOT_VERIFIED | JWT 검증 실패, deviceId claim 부재, 또는 email OTP step-up 미검증 |
| 403 | 1000 | Native 2FA is not available for the clinical cohort | clinical cohort 는 native 2FA 불가 (eID 전용) |
| 429 | 1000 | ThrottlerException: Too Many Requests | Endpoint throttle 5/시간 |
다음 단계
- 1. keyless 직후 → 서비스 활성화:
POST /v2/auth/user-cycle/activate. - 2. 재로그인 후 → 일반 보호 endpoint 호출 (user access token 으로).
- 3. link 후 → 이후 로그인은 native(디바이스 키) 경로로.
- eID 로그인: eID 로그인.
- Native 2FA 가입: Native 2FA 회원가입.
- 마스터 문서: domain endpoints.md (로그인).