# LMU AI 계정으로 로그인

> 웹사이트나 앱에 "LMU AI 계정으로 로그인"을 추가하세요. 사용자를 승인 페이지로 리디렉션하고, 콜백에서 state를 검증한 뒤, 서버에서 client_secret으로 ticket을 사용자 신원 정보로 교환합니다. 흐름, 엔드포인트, 사용자 필드, 보안 요구 사항, 샘플 코드, 그리고 AI 코딩 어시스턴트에 바로 전달할 수 있는 프롬프트를 다룹니다.

URL: https://docs.lmuai.com/ko/docs/api/lmu-account-login



"LMU AI 계정으로 로그인"을 사용하면 다른 웹사이트와 앱(커뮤니티, 크리에이티브 도구, 내부 프로젝트 등)에서 사용자가 LMU AI 계정으로 로그인할 수 있습니다. 사용자는 LMU AI에서 "승인 및 로그인"을 한 번 클릭하기만 하면 됩니다. 계정이 없는 사용자는 그 자리에서 LMU AI에 가입할 수 있으며, 가입 후 자동으로 사이트로 돌아옵니다.

사이트는 사용자의 **신원 정보**만 받습니다. &#x2A;*사용자의 API 키나 잔액은 절대 받지 않습니다.**

<Callout type="info" title="사용 대상">
  웹사이트나 앱에 **서버**가 필요합니다. 마지막 단계에서 `client_secret`을 사용해 ticket을 교환하는데, `client_secret`은 브라우저, 미니 프로그램, 모바일 프론트엔드 코드에 절대 나타나서는 안 됩니다. 프론트엔드만 있는 프로젝트는 자체 백엔드를 통해 교환해야 합니다.
</Callout>

## 1. 동작 방식 [#1-동작-방식]

이 흐름은 간소화된 OAuth 인가 코드 흐름입니다. 브라우저 주소창에는 **60초 동안만 유효한 일회용 ticket**만 전달되며, **서버**가 `client_secret`으로 이를 사용자 신원 정보와 교환합니다.



## 2. 시작하기 전에: 접근 신청 [#2-시작하기-전에-접근-신청]

다음 항목을 LMU AI에 등록하세요.

| 항목        | 설명                                                                                                                                                                                              |
| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 프로젝트 이름   | 승인 페이지에 표시됩니다. 예: "立刻图 liketu.ai"                                                                                                                                                               |
| 콜백 URL    | HTTPS를 사용해야 하며 **전체 URL이 정확히** 일치해야 합니다. 예: `https://your-app.example.com/sso/callback`. 테스트 환경용으로 두 번째 URL을 등록할 수 있으며, 로컬 개발용으로 `http://localhost` 또는 `http://127.0.0.1`로 시작하는 URL도 등록할 수 있습니다 |
| 로고(선택)    | 승인 페이지에 표시됩니다                                                                                                                                                                                   |
| 이메일 필요 여부 | 기본적으로 이메일은 반환되지 않습니다. 필요한 경우 이유를 설명해 주세요. 승인 후에만 반환됩니다                                                                                                                                          |

등록이 완료되면 다음을 받습니다.

* `client_id`: 프로젝트를 식별하며 공개되어도 됩니다.
* `client_secret`: 프로젝트 시크릿입니다. **한 번만 표시**되므로 서버 측 환경 변수나 시크릿 관리자에 보관하세요.

<Callout type="info" title="신청 방법">
  LMU AI 내부 프로젝트는 관리자가 관리 콘솔에서 생성합니다. 외부 파트너는 LMU AI 고객 지원에 문의하여 신청하세요.
</Callout>

## 3. Base URL [#3-base-url]

```text
https://api.lmuai.com
```

계정은 LMU AI 중국 본토 사이트와 국제 사이트 모두에서 사용할 수 있으며, 동일한 사용자는 두 사이트에서 같은 사용자 ID를 가집니다. 이 페이지의 예시는 현재 사이트 주소를 사용합니다.

<Callout type="warn" title="승인과 교환은 같은 사이트에서">
  ticket은 이를 발급한 사이트에 저장됩니다. 사용자를 승인하도록 보낸 사이트와 같은 사이트에서 ticket을 교환해야 하며, 그렇지 않으면 교환이 실패합니다(`401`).
</Callout>

## 4. 1단계: 승인 페이지로 리디렉션 [#4-1단계-승인-페이지로-리디렉션]

### `GET /sso/authorize` [#get-ssoauthorize]

사용자가 "LMU AI 계정으로 로그인"을 클릭하면, 서버는 무작위 `state`를 생성해 쿠키에 저장하고 브라우저를 승인 페이지로 302 리디렉션합니다.

```text
https://api.lmuai.com/sso/authorize?client_id=YOUR_CLIENT_ID&state=RANDOM_STATE&redirect_uri=https%3A%2F%2Fyour-app.example.com%2Fsso%2Fcallback
```

| 매개변수           | 필수  | 설명                                                                                                         |
| -------------- | --- | ---------------------------------------------------------------------------------------------------------- |
| `client_id`    | 예   | 신청 시 받은 프로젝트 식별자                                                                                           |
| `state`        | 예   | 로그인할 때마다 새로 생성하는 무작위 문자열로, `A-Z a-z 0-9 _ -`만 포함하며 길이는 16–128자입니다. 32바이트 난수를 base64url로 인코딩한 값(43자)을 권장합니다 |
| `redirect_uri` | 아니요 | 콜백 URL로, 등록된 URL 중 하나와 **정확히** 일치해야 합니다. 생략하면 첫 번째로 등록된 URL이 사용됩니다                                         |

LMU AI 측에서 사용자는 다음을 진행합니다.

1. 로그인되어 있지 않으면 먼저 로그인하거나, 계정이 없으면 그 자리에서 가입한 후 자동으로 승인 페이지로 돌아옵니다.
2. 승인 페이지에서 프로젝트 이름과 "로그인에만 사용됩니다. API 키와 잔액은 공유되지 않습니다."라는 안내를 확인합니다.
3. "승인 및 로그인" 또는 "취소"를 클릭합니다.

<Callout type="warn" title="승인 페이지를 iframe에 포함하지 마세요">
  승인 페이지는 포함할 수 없습니다(`X-Frame-Options: DENY`). 전체 페이지를 리디렉션하거나 새 창에서 여세요.
</Callout>

## 5. 2단계: 콜백 처리 [#5-2단계-콜백-처리]

사용자가 승인하면 브라우저가 콜백 URL로 리디렉션됩니다.

```text
https://your-app.example.com/sso/callback?ticket=TICKET&state=RANDOM_STATE
```

사용자가 취소한 경우:

```text
https://your-app.example.com/sso/callback?error=access_denied&state=RANDOM_STATE
```

콜백 핸들러는 다음을 순서대로 수행해야 합니다.

1. **state 확인**: URL의 `state`가 쿠키에 저장된 값과 정확히 일치해야 하며, 확인 직후 쿠키를 삭제합니다. 일치하지 않으면 로그인을 거부합니다.
2. **취소 처리**: `error=access_denied`인 경우 로그인 페이지로 돌아가 "승인을 취소했습니다."라고 안내합니다.
3. **신원 정보 교환**: 서버에서 `ticket`으로 3단계의 교환 엔드포인트를 호출합니다.
4. **계정 생성 또는 로그인**: 사용자의 `id`로 로컬 계정을 찾거나 생성하고, 사이트의 세션을 설정합니다.
5. **원래 페이지로 복귀**: 오픈 리디렉션으로 악용되지 않도록 자체 사이트의 상대 경로만 허용합니다.

## 6. 3단계: 서버에서 ticket을 사용자 신원 정보로 교환 [#6-3단계-서버에서-ticket을-사용자-신원-정보로-교환]

### `POST /api/v1/sso/exchange` [#post-apiv1ssoexchange]

이 단계는 **반드시 서버에서만 호출**해야 합니다.

```bash
curl https://api.lmuai.com/api/v1/sso/exchange \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "YOUR_CLIENT_ID",
    "client_secret": "YOUR_CLIENT_SECRET",
    "ticket": "TICKET"
  }'
```

| 매개변수            | 필수 | 설명                 |
| --------------- | -- | ------------------ |
| `client_id`     | 예  | 프로젝트 식별자           |
| `client_secret` | 예  | 프로젝트 시크릿           |
| `ticket`        | 예  | 콜백 URL에서 받은 ticket |

성공 응답(HTTP `200`):

```json
{
  "code": 0,
  "message": "success",
  "data": {
    "user": {
      "id": 10086,
      "username": "alice",
      "email": "alice@example.com",
      "avatar": "https://example.com/avatar.png"
    }
  }
}
```

실패 시:

| HTTP  | 원인                                                                            | 조치                                           |
| ----- | ----------------------------------------------------------------------------- | -------------------------------------------- |
| `401` | `client_id` 또는 `client_secret`이 잘못됨, ticket이 만료되었거나 이미 사용되었거나 이 프로젝트에 발급되지 않음 | 같은 ticket으로 재시도하지 말고, 사용자가 로그인을 다시 시작하도록 합니다 |
| `404` | 이 사이트에서 LMU AI 계정 로그인이 활성화되지 않음                                               | Base URL이 신청한 사이트인지 확인합니다                    |
| `429` | 요청이 너무 많음                                                                     | 잠시 후 재시도합니다                                  |
| `5xx` | 서비스를 일시적으로 사용할 수 없음                                                           | 잠시 후 사용자가 로그인을 다시 시작하도록 합니다                  |

탐색 공격을 방지하기 위해 자격 증명이나 ticket 관련 오류는 어떤 항목이 잘못되었는지 알려 주지 않고 모두 동일한 `401`을 반환합니다.

**ticket 규칙**: 60초 동안 유효하고, 한 번만 교환할 수 있으며, 발급된 프로젝트만, 그리고 발급한 사이트에서만 교환할 수 있습니다.

## 7. 사용자 필드 [#7-사용자-필드]

| 필드         | 유형      | 설명                                                                                  |
| ---------- | ------- | ----------------------------------------------------------------------------------- |
| `id`       | integer | 고유 사용자 ID입니다. 절대 변경되지 않으며 중국 본토 사이트와 국제 사이트에서 동일합니다. **자체 서비스에서 계정을 연결하는 키로 사용하세요** |
| `username` | string  | 사용자 이름입니다. 사용자가 변경할 수 있으며 비어 있을 수 있습니다. 비어 있으면 "LMU AI 사용자 `{id}`"와 같은 기본값을 표시하세요   |
| `email`    | string  | 이메일 수신이 승인된 프로젝트에만 반환되며, 그렇지 않으면 비어 있습니다. 사용자가 변경할 수 있으며 **인증된 이메일임이 보장되지 않습니다**    |
| `avatar`   | string  | 아바타 URL이며 비어 있을 수 있습니다                                                              |

<Callout type="warn" title="이메일로 계정을 자동 병합하지 마세요">
  사이트에 이미 이메일로 가입한 계정이 있는 경우, LMU AI가 반환한 이메일이 일치한다는 이유만으로 사용자를 기존 계정에 바로 로그인시키지 **마세요**. 먼저 해당 이메일로 인증 코드를 보내고, 인증된 후에만 계정을 연결하세요. 해당 이메일을 사용하는 계정이 없다면 새 계정을 생성하면 됩니다.
</Callout>

## 8. 보안 요구 사항 [#8-보안-요구-사항]

출시 전에 각 항목을 확인하세요.

* [ ] `client_secret`은 서버에만 존재해야 합니다. 프론트엔드 코드, 로그, URL, 코드 저장소에 절대 포함하지 마세요.
* [ ] 로그인할 때마다 새 `state`를 생성하고, 5분 후 만료되는 httpOnly, Secure, SameSite=Lax 쿠키에 저장하세요. 콜백에서 일치하면 즉시 삭제하세요.
* [ ] 콜백 URL은 HTTPS를 사용하고 등록된 URL과 정확히 일치해야 합니다.
* [ ] `ticket`을 받으면 즉시 교환하세요. 재시도, 캐시, 로그 기록을 하지 마세요.
* [ ] 계정은 `id`로 연결하고, 이메일로 기존 계정을 자동 병합하지 마세요.
* [ ] 로그인 후에는 자체 사이트의 상대 경로로만 리디렉션하세요.
* [ ] 교환 호출에 타임아웃을 설정하세요(10초 권장). 실패 시 빈 화면을 남기지 말고 이유와 함께 로그인 페이지로 돌아가세요.
* [ ] 사이트에서 로그아웃하면 자체 세션만 삭제되며, 사용자의 LMU AI 로그인 상태에는 영향을 주지 않습니다.

## 9. 샘플 코드 [#9-샘플-코드]

두 샘플 모두 `/sso/login`(승인 페이지로 리디렉션)과 `/sso/callback`(state 확인 및 신원 정보 교환)을 구현합니다. 사용하는 환경 변수:

| 변수                      | 예시                                          |
| ----------------------- | ------------------------------------------- |
| `LMU_SSO_BASE_URL`      | `https://api.lmuai.com`                     |
| `LMU_SSO_CLIENT_ID`     | 발급받은 `client_id`                            |
| `LMU_SSO_CLIENT_SECRET` | 발급받은 `client_secret`                        |
| `LMU_SSO_REDIRECT_URI`  | `https://your-app.example.com/sso/callback` |

<Tabs items="['Node.js (Express)', 'Python (FastAPI)']">
  <Tab value="Node.js (Express)">
    ```js
    import express from "express";
    import cookieParser from "cookie-parser";
    import crypto from "node:crypto";

    const BASE = process.env.LMU_SSO_BASE_URL;
    const CLIENT_ID = process.env.LMU_SSO_CLIENT_ID;
    const CLIENT_SECRET = process.env.LMU_SSO_CLIENT_SECRET;
    const REDIRECT_URI = process.env.LMU_SSO_REDIRECT_URI;
    const STATE_COOKIE = "lmu_sso_state";

    const app = express();
    app.use(cookieParser());

    // Step 1: generate state and redirect to the LMU AI authorization page
    app.get("/sso/login", (req, res) => {
      const state = crypto.randomBytes(32).toString("base64url");
      res.cookie(STATE_COOKIE, state, {
        httpOnly: true, secure: true, sameSite: "lax", maxAge: 5 * 60 * 1000, path: "/",
      });
      const url = new URL("/sso/authorize", BASE);
      url.searchParams.set("client_id", CLIENT_ID);
      url.searchParams.set("state", state);
      url.searchParams.set("redirect_uri", REDIRECT_URI);
      res.redirect(url.toString());
    });

    // Steps 2 and 3: check state and exchange for identity on the server
    app.get("/sso/callback", async (req, res) => {
      const { ticket, state, error } = req.query;
      const expected = req.cookies[STATE_COOKIE];
      res.clearCookie(STATE_COOKIE, { path: "/" });

      const ok = typeof state === "string" && typeof expected === "string" &&
        Buffer.byteLength(state) === Buffer.byteLength(expected) &&
        crypto.timingSafeEqual(Buffer.from(state), Buffer.from(expected));
      if (!ok) return res.redirect("/login?error=invalid_state");
      if (error === "access_denied") return res.redirect("/login?error=cancelled");
      if (typeof ticket !== "string" || !ticket) return res.redirect("/login?error=missing_ticket");

      let user;
      try {
        const resp = await fetch(new URL("/api/v1/sso/exchange", BASE), {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, ticket }),
          signal: AbortSignal.timeout(10_000),
        });
        if (!resp.ok) return res.redirect("/login?error=exchange_failed");
        user = (await resp.json()).data.user; // { id, username, email, avatar }
      } catch {
        return res.redirect("/login?error=exchange_failed");
      }

      // Replace with your own logic: find or create the local account by user.id, then set your session
      // const localUser = await upsertUserByLmuId(user);
      // await createSession(res, localUser);

      res.redirect("/");
    });

    app.listen(3000);
    ```
  </Tab>

  <Tab value="Python (FastAPI)">
    ```python
    import hmac
    import os
    import secrets
    from urllib.parse import urlencode

    import httpx
    from fastapi import FastAPI, Request
    from fastapi.responses import RedirectResponse

    BASE = os.environ["LMU_SSO_BASE_URL"]
    CLIENT_ID = os.environ["LMU_SSO_CLIENT_ID"]
    CLIENT_SECRET = os.environ["LMU_SSO_CLIENT_SECRET"]
    REDIRECT_URI = os.environ["LMU_SSO_REDIRECT_URI"]
    STATE_COOKIE = "lmu_sso_state"

    app = FastAPI()


    def back_to_login(reason: str) -> RedirectResponse:
        resp = RedirectResponse(f"/login?error={reason}", status_code=302)
        resp.delete_cookie(STATE_COOKIE)
        return resp


    # Step 1: generate state and redirect to the LMU AI authorization page
    @app.get("/sso/login")
    def sso_login():
        state = secrets.token_urlsafe(32)
        query = urlencode({"client_id": CLIENT_ID, "state": state, "redirect_uri": REDIRECT_URI})
        resp = RedirectResponse(f"{BASE}/sso/authorize?{query}", status_code=302)
        resp.set_cookie(STATE_COOKIE, state, max_age=300, httponly=True, secure=True, samesite="lax")
        return resp


    # Steps 2 and 3: check state and exchange for identity on the server
    @app.get("/sso/callback")
    async def sso_callback(request: Request, ticket: str | None = None,
                           state: str | None = None, error: str | None = None):
        expected = request.cookies.get(STATE_COOKIE)
        if not state or not expected or not hmac.compare_digest(state.encode(), expected.encode()):
            return back_to_login("invalid_state")
        if error == "access_denied":
            return back_to_login("cancelled")
        if not ticket:
            return back_to_login("missing_ticket")

        try:
            async with httpx.AsyncClient(timeout=10) as client:
                resp = await client.post(f"{BASE}/api/v1/sso/exchange", json={
                    "client_id": CLIENT_ID,
                    "client_secret": CLIENT_SECRET,
                    "ticket": ticket,
                })
            resp.raise_for_status()
            user = resp.json()["data"]["user"]  # {"id", "username", "email", "avatar"}
        except (httpx.HTTPError, ValueError, KeyError, TypeError):
            return back_to_login("exchange_failed")

        # Replace with your own logic: find or create the local account by user["id"], then set your session
        # local_user = upsert_user_by_lmu_id(user)

        done = RedirectResponse("/", status_code=302)
        done.delete_cookie(STATE_COOKIE)
        # create_session(done, local_user)
        return done
    ```
  </Tab>
</Tabs>

## 10. AI 코딩 어시스턴트에게 연동 맡기기 [#10-ai-코딩-어시스턴트에게-연동-맡기기]

아래 프롬프트를 프로젝트와 함께 Claude Code, Codex, Cursor 같은 AI 코딩 어시스턴트에 전달하고, 꺾쇠괄호 안의 부분을 프로젝트에 맞게 바꾸세요.

```text
Integrate "Sign in with LMU AI" into this project. Implement it exactly as specified below; do not change the endpoints, parameters, or security requirements.

[Configuration] Add 4 server-side environment variables. They must never appear in front-end code, logs, or the code repository:
- LMU_SSO_BASE_URL=https://api.lmuai.com
- LMU_SSO_CLIENT_ID=<your client_id>
- LMU_SSO_CLIENT_SECRET=<your client_secret>
- LMU_SSO_REDIRECT_URI=https://<this project's domain>/sso/callback (must exactly match the callback URL registered with LMU AI)

[Step 1] Add GET /sso/login:
- Generate state as the base64url encoding of 32 random bytes;
- Store it in the cookie lmu_sso_state: httpOnly, Secure, SameSite=Lax, expires in 5 minutes;
- 302-redirect to {LMU_SSO_BASE_URL}/sso/authorize?client_id=…&state=…&redirect_uri=… (URL-encode the parameters).

[Step 2] Add GET /sso/callback:
- The state URL parameter must exactly match the cookie lmu_sso_state (use a constant-time comparison); delete the cookie right after comparing. If they do not match, return to the login page with "The sign-in link has expired. Please sign in again.";
- If error=access_denied, return to the login page with "You cancelled authorization.";
- If ticket is missing, return to the login page with the reason.

[Step 3] Exchange the ticket for the identity on the server:
- POST {LMU_SSO_BASE_URL}/api/v1/sso/exchange with Content-Type: application/json and body {"client_id","client_secret","ticket"}, timeout 10 seconds;
- On success (HTTP 200) the user is in data.user of the response JSON: {id, username, email, avatar};
- On failure (401/404/429/5xx or timeout), do not retry the same ticket; return to the login page with "Sign-in failed. Please try again.";
- A ticket can be used only once and expires in 60 seconds; do not cache or log it.

[Accounts]
- Use data.user.id as the unique key for linking LMU AI accounts (add a field such as lmu_user_id with a unique index): update the username and avatar if it exists, otherwise create a new account;
- Do not sign the user into an existing local account just because the email matches: send a verification code to that email first and link only after it is verified;
- username may be empty — show "LMU AI user {id}" in that case; email may be empty.
- After creating or signing in the account, set this project's session and redirect to the page the user came from (relative paths on this site only).

[UI] Add a "Sign in with LMU AI" button to the login page that goes to /sso/login.

[Tests] Add automated tests covering at least:
1. Normal sign-in: account created on first sign-in, profile updated on the next sign-in;
2. Missing, mismatched, or tampered state is rejected;
3. error=access_denied returns to the login page with a cancellation notice;
4. A 401 from exchange returns to the login page without retrying;
5. Reusing the same ticket fails;
6. An existing account with the same email can only be linked after email verification.

When done, list the changed files, the new environment variables, and the test results.
```

## 11. 출시 전 점검 [#11-출시-전-점검]

* [ ] 이미 LMU AI에 로그인한 사용자: "LMU AI 계정으로 로그인" 클릭 → 승인 페이지에서 "승인 및 로그인" 클릭 → 사이트로 돌아와 로그인된 상태.
* [ ] LMU AI 계정이 없는 신규 사용자: 로그인 클릭 → LMU AI에 가입 → 승인 → 사이트로 돌아와 로그인된 상태.
* [ ] 승인 페이지에서 "취소" 클릭: 로그인 페이지로 돌아오며 승인이 취소되었다는 안내가 표시됨.
* [ ] 콜백 페이지 새로고침, 같은 ticket 재사용, 변조되었거나 누락된 state: 모두 실패하고 이유와 함께 로그인 페이지로 돌아감.
* [ ] 같은 LMU AI 사용자가 두 번째로 로그인하면 같은 로컬 계정으로 들어가고 프로필이 업데이트됨.
* [ ] `client_secret`이 프론트엔드 코드, 브라우저 네트워크 요청, 로그에 절대 나타나지 않음.
