44406b72db
Moteva-style AI design workbench replica. Home prompt creates a project; the project page provides an infinite canvas with node drag, zoom/pan, a design chat panel, history replay, image asset upload, and project save/regenerate. - Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage; sky-valley/pi agent runtime adapter. - Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n, shadcn/ui components, auth (OTP/Turnstile/Google/WeChat). - Deploy: Docker Compose and k3s manifests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
4.1 KiB
Go
134 lines
4.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type googleTokenInfo struct {
|
|
Audience string `json:"aud"`
|
|
Subject string `json:"sub"`
|
|
Email string `json:"email"`
|
|
EmailVerified string `json:"email_verified"`
|
|
Name string `json:"name"`
|
|
Error string `json:"error"`
|
|
ErrorDesc string `json:"error_description"`
|
|
}
|
|
|
|
type wechatTokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
OpenID string `json:"openid"`
|
|
UnionID string `json:"unionid"`
|
|
Scope string `json:"scope"`
|
|
ErrCode int64 `json:"errcode"`
|
|
ErrMsg string `json:"errmsg"`
|
|
}
|
|
|
|
func (s *Service) verifyGoogleIDToken(ctx context.Context, idToken string) (GoogleIdentity, error) {
|
|
idToken = strings.TrimSpace(idToken)
|
|
if idToken == "" {
|
|
return GoogleIdentity{}, fmt.Errorf("%w: idToken is required", ErrInvalidCredentials)
|
|
}
|
|
endpoint := strings.TrimSpace(s.cfg.GoogleTokenInfo)
|
|
if endpoint == "" || strings.TrimSpace(s.cfg.GoogleClientID) == "" {
|
|
return GoogleIdentity{}, ErrProviderNotReady
|
|
}
|
|
values := url.Values{}
|
|
values.Set("id_token", idToken)
|
|
requestURL := endpoint + "?" + values.Encode()
|
|
timeout := s.cfg.GoogleTimeout
|
|
if timeout <= 0 {
|
|
timeout = 8 * time.Second
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
|
if err != nil {
|
|
return GoogleIdentity{}, err
|
|
}
|
|
client := &http.Client{Timeout: timeout}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return GoogleIdentity{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var info googleTokenInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
return GoogleIdentity{}, err
|
|
}
|
|
if resp.StatusCode >= 400 || info.Error != "" {
|
|
if info.ErrorDesc != "" {
|
|
return GoogleIdentity{}, fmt.Errorf("%w: %s", ErrInvalidCredentials, info.ErrorDesc)
|
|
}
|
|
return GoogleIdentity{}, ErrInvalidCredentials
|
|
}
|
|
if info.Audience != strings.TrimSpace(s.cfg.GoogleClientID) {
|
|
return GoogleIdentity{}, fmt.Errorf("%w: google audience mismatch", ErrInvalidCredentials)
|
|
}
|
|
if info.Subject == "" || info.Email == "" {
|
|
return GoogleIdentity{}, fmt.Errorf("%w: google identity is incomplete", ErrInvalidCredentials)
|
|
}
|
|
if info.EmailVerified != "" && !strings.EqualFold(info.EmailVerified, "true") {
|
|
return GoogleIdentity{}, fmt.Errorf("%w: google email is not verified", ErrInvalidCredentials)
|
|
}
|
|
return GoogleIdentity{
|
|
Subject: info.Subject,
|
|
Email: normalizeEmail(info.Email),
|
|
Name: strings.TrimSpace(info.Name),
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) exchangeWechatCodeHTTP(ctx context.Context, code string) (WechatIdentity, error) {
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
return WechatIdentity{}, fmt.Errorf("%w: code is required", ErrInvalidCredentials)
|
|
}
|
|
if strings.TrimSpace(s.cfg.WechatAppID) == "" || strings.TrimSpace(s.cfg.WechatAppSecret) == "" {
|
|
return WechatIdentity{}, ErrProviderNotReady
|
|
}
|
|
endpoint := strings.TrimSpace(s.cfg.WechatTokenURL)
|
|
if endpoint == "" {
|
|
return WechatIdentity{}, ErrProviderNotReady
|
|
}
|
|
values := url.Values{}
|
|
values.Set("appid", s.cfg.WechatAppID)
|
|
values.Set("secret", s.cfg.WechatAppSecret)
|
|
values.Set("code", code)
|
|
values.Set("grant_type", "authorization_code")
|
|
requestURL := endpoint + "?" + values.Encode()
|
|
timeout := s.cfg.WechatTimeout
|
|
if timeout <= 0 {
|
|
timeout = 8 * time.Second
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
|
|
if err != nil {
|
|
return WechatIdentity{}, err
|
|
}
|
|
client := &http.Client{Timeout: timeout}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return WechatIdentity{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var token wechatTokenResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
|
|
return WechatIdentity{}, err
|
|
}
|
|
if resp.StatusCode >= 400 || token.ErrCode != 0 {
|
|
if token.ErrMsg != "" {
|
|
return WechatIdentity{}, fmt.Errorf("%w: %s", ErrInvalidCredentials, token.ErrMsg)
|
|
}
|
|
return WechatIdentity{}, ErrInvalidCredentials
|
|
}
|
|
if token.OpenID == "" {
|
|
return WechatIdentity{}, fmt.Errorf("%w: wechat identity is incomplete", ErrInvalidCredentials)
|
|
}
|
|
return WechatIdentity{
|
|
OpenID: strings.TrimSpace(token.OpenID),
|
|
UnionID: strings.TrimSpace(token.UnionID),
|
|
}, nil
|
|
}
|