yao/openapi/otp/DESIGN.md
Max 37ab2bc84b Enhance user login functionality with options for customization
- Introduce `LoginWithOptions` method to allow for customizable login flows, enabling overrides for scopes, token expiration, and refresh token issuance.
- Add `LoginOptions` struct to encapsulate optional parameters for login, improving flexibility in user authentication.
- Update token handling in `issueTokens` to accommodate new options, ensuring proper management of access and refresh tokens based on user preferences.
- Integrate OTP service initialization into the OpenAPI server setup for enhanced authentication capabilities.
2026-02-21 21:38:39 +08:00

334 lines
10 KiB
Markdown

# OTP Passwordless Authentication
## Overview
OTP (One-Time Password) provides passwordless authentication via magic links.
AI or system generates a short link `https://host/<prefix>/v/<code>`, user clicks it,
CUI verifies the code against the backend, backend issues tokens, and frontend redirects.
## Architecture
```
AI/System Yao Backend CUI Frontend
| | |
|-- otp.Create(params) ------->| |
|<---- code (nanoid 12) -------| |
| | |
| (compose link, send to user) | |
| | |
| | GET /<prefix>/v/<code> |
| |<-------------------------------|
| | |
| | POST /api/otp/login {code} |
| |<-------------------------------|
| | |
| |-- otp.Login(code) ------------>|
| | ├─ Verify code in store |
| | ├─ Resolve identity |
| | ├─ LoginByTeamID → tokens |
| | └─ SendLoginCookies |
| | |
| | {redirect} ----------------->|
| | |
| | window.location = redirect
```
## Design Principles
1. **Four clean APIs**: Create, Verify, Login, Revoke — each with a single responsibility.
2. **Verify is pure**: returns stored payload, no side effects.
Login is the full flow: verify + identity resolution + token issuance.
3. **Package location `openapi/otp/`**: can freely import `user` and `oauth`.
Dependency chain is one-directional: `openapi/otp → user → oauth` (no cycles).
4. **Shared store, unified key namespace**: reuses OAuth's store.Store,
keys under `{prefix}oauth:otp:{code}` alongside refresh_token/access_token.
## Package Structure
```
openapi/
otp/
DESIGN.md ← this file
otp.go ← Service, Payload, NewService
generate.go ← Create (nanoid + collision check + store.Set)
verify.go ← Verify (store.Get + type coercion)
revoke.go ← Revoke (store.Del)
login.go ← Login (Verify + resolve identity + issue tokens)
handler.go ← GinOTPCreate + GinOTPLogin HTTP handlers + Attach(group, oauth)
process.go ← Yao processors: otp.Create, otp.Verify, otp.Login, otp.Revoke
openapi.go ← init OTP service in Load(), register route in Attach()
```
```
cui/packages/cui/
openapi/user/auth.ts ← add OTPLogin method
pages/auth/v/$.tsx ← OTP verification page (route: /v/<code>)
```
## Dependency Graph
```
openapi/otp
├── imports user (LoginByTeamID, LoginWithOptions, SendLoginCookies)
├── imports oauth (OAuth.GetUserProvider, OAuth.GetStore for identity resolution)
└── imports store (store.Store for code persistence)
user
└── imports oauth (existing, unchanged)
```
One-directional: `openapi/otp → user → oauth`. No cycles.
## Data Structures
### Payload (stored in store)
```go
type Payload struct {
TeamID string `json:"team_id,omitempty"`
MemberID string `json:"member_id,omitempty"`
UserID string `json:"user_id,omitempty"`
Redirect string `json:"redirect"`
Scope string `json:"scope,omitempty"`
}
```
### GenerateParams
```go
type GenerateParams struct {
TeamID string
MemberID string
UserID string
ExpiresIn int // seconds, default 24h
Redirect string // required
Scope string // optional, space-separated
}
```
## Store Key Format
```
{prefix}oauth:otp:{code}
```
Example: `yao_:oauth:otp:abc123def456`
Consistent with existing OAuth keys:
- `{prefix}oauth:refresh_token:{token}`
- `{prefix}oauth:access_token:{token}`
- `{prefix}oauth:otp:{code}`
NanoID: 12 chars, alphabet `23456789abcdefghjkmnpqrstuvwxyz` (no ambiguous chars).
Collision check: retry up to 5 times.
## Processor Interface
Four processors, CRUD-style naming.
### otp.Create
```javascript
code = Process("otp.Create", {
"user_id": "user_xxx", // required (or member_id)
"team_id": "team_xxx", // optional
"member_id": "member_xxx", // optional; when set, team_id is required
"expires_in": 86400, // optional; seconds, default 24h
"redirect": "/chat", // required; target path after login
"scope": "read write" // optional; space-separated scopes
})
// returns: "abc123def456" (string)
// developer composes the full link: `${host}/${prefix}/v/${code}`
```
Single map argument. Returns code string.
### otp.Verify
```javascript
payload = Process("otp.Verify", "abc123def456")
// returns: {
// "team_id": "team_xxx",
// "member_id": "member_xxx",
// "user_id": "user_xxx",
// "redirect": "/chat",
// "scope": "read write"
// }
```
Pure validation. Returns stored Payload. Does NOT consume code (valid within TTL).
Use case: inspect payload before login, or use OTP for non-login purposes.
### otp.Login
```javascript
result = Process("otp.Login", "abc123def456", "zh-CN")
// returns: {
// "access_token": "Bearer ...",
// "id_token": "eyJ...",
// "redirect": "/chat",
// "expires_in": 3600,
// ...
// }
```
Full login flow: verify code -> resolve identity -> issue tokens.
- args[0]: code (string, required)
- args[1]: locale (string, optional)
Internally:
1. Verify(code) -> Payload
2. Resolve identity (member_id -> user_id if needed)
3. LoginByTeamID or LoginWithOptions (when scope override)
4. Return LoginResponse + redirect
Does NOT set HTTP cookies (no gin.Context). The HTTP handler wraps this
and additionally calls SendLoginCookies.
### otp.Revoke
```javascript
Process("otp.Revoke", "abc123def456")
// returns: null
```
Immediately removes code from store. Silent on missing/expired.
## HTTP APIs
### POST /api/otp/create (protected)
Requires authentication (OpenAPI Guard). Permission managed by Scope/ACL.
The caller's `team_id` is forced from the authenticated identity; request body `team_id` is ignored.
The `member_id` must belong to the caller's team.
**Request:**
```json
{
"member_id": "member_xxx",
"user_id": "user_xxx",
"expires_in": 86400,
"redirect": "/chat",
"scope": "read write"
}
```
**Success (200):**
```json
{ "code": "abc123def456" }
```
**Errors:** 400 (missing fields), 403 (member not in team), 500 (internal).
### POST /api/otp/login (public)
Public endpoint (no auth guard). The OTP code itself is the credential.
**Request:**
```json
{ "code": "abc123def456", "locale": "zh-CN" }
```
**Success (200):**
```json
{ "redirect": "/chat" }
```
Cookies set: `access_token`, `refresh_token`, `session_id`.
**Errors:** 400 (missing code), 401 (invalid/expired), 500 (internal).
### Handler Flows (handler.go)
#### GinOTPCreate
```
1. authorized.GetInfo(c) -> authInfo (teamID, userID)
2. Bind JSON -> request body
3. Force teamID from authInfo
4. Validate member belongs to team
5. service.Create(params) -> code
6. Respond {code}
```
#### GinOTPLogin
```
1. Bind JSON -> {code, locale}
2. service.Login(code, locale) -> LoginResponse + Payload
3. sessionID = utils.GetSessionID(c) or generateSessionID()
4. user.SendLoginCookies(c, loginResp, sessionID)
5. Respond {redirect: payload.Redirect}
```
The handlers are thin — business logic lives in the Service methods.
## Scope Override (user package change)
When `payload.Scope` is non-empty, `LoginByTeamID` cannot be used directly
because it resolves scopes internally. Add one function to `user` package:
```go
// user/types.go
type LoginOptions struct {
Scopes []string
}
// user/login.go
func LoginWithOptions(userid, teamID string, loginCtx *LoginContext, opts *LoginOptions) (*LoginResponse, error)
```
Same logic as `LoginByTeamID`, uses `opts.Scopes` when non-nil.
This is the **only** change to `user` package.
## Initialization (openapi.go)
In `Load()`:
```go
otp.NewService(oauth.OAuth.GetStore(), oauth.OAuth.GetPrefix())
```
Note: Since oauth.Service.prefix is private, the OTP service constructs
the prefix independently using `share.App.Prefix` for consistency.
In `Attach()`:
```go
otp.Attach(group.Group("/otp"), openapi.OAuth)
```
## CUI Page (pages/auth/v/$.tsx)
```
1. Extract code from URL path: /v/<code>
2. Call userClient.auth.OTPLogin(code, locale)
3. On success:
- Call GetProfile() to get UserInfo (cookies already set by backend)
- AfterLogin(global, { user: profileData, entry: redirect })
- window.location.href = redirect
4. On error: show error UI with "Go Back" button
```
### auth.ts Addition
```typescript
async OTPLogin(code: string, locale?: string): Promise<ApiResponse<{ redirect: string }>> {
return this.api.Post<{ redirect: string }>('/otp/login', { code, locale: locale || '' })
}
```
## Test Plan
- **Unit tests** (openapi/otp package):
- Create: produces 12-char code, stores payload, respects TTL
- Create: validates required fields (user_id/member_id, redirect)
- Create: handles collision retry
- Verify: returns payload, rejects expired/invalid/empty
- Verify: does NOT consume code (multi-verify within TTL)
- Revoke: removes code, silent on missing
- Login: full flow with valid code returns tokens
- Login: rejects invalid code
- **Handler tests**:
- POST /api/otp/create with valid auth -> 200 + code
- POST /api/otp/create without auth -> 401
- POST /api/otp/create with cross-team member -> 403
- POST /api/otp/login with valid code -> 200 + cookies + redirect
- POST /api/otp/login with invalid code -> 401
- POST /api/otp/login with missing code -> 400