Initial commit: img-infinite-canvas AI design workbench MVP
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>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type tokenPayload struct {
|
||||
Subject string `json:"sub"`
|
||||
Type string `json:"typ,omitempty"`
|
||||
OpenID string `json:"openid,omitempty"`
|
||||
UnionID string `json:"unionid,omitempty"`
|
||||
Issued int64 `json:"iat"`
|
||||
Expires int64 `json:"exp"`
|
||||
}
|
||||
|
||||
func signToken(userID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 7 * 24 * time.Hour
|
||||
}
|
||||
return signTypedToken(userID, "access", secret, ttl, now)
|
||||
}
|
||||
|
||||
func signRefreshToken(userID string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * 24 * time.Hour
|
||||
}
|
||||
return signTypedToken(userID, "refresh", secret, ttl, now)
|
||||
}
|
||||
|
||||
func signTypedToken(userID string, tokenType string, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
|
||||
payload := tokenPayload{
|
||||
Subject: userID,
|
||||
Type: tokenType,
|
||||
Issued: now.Unix(),
|
||||
Expires: now.Add(ttl).Unix(),
|
||||
}
|
||||
token, err := signPayload(payload, secret)
|
||||
return token, int64(ttl.Seconds()), err
|
||||
}
|
||||
|
||||
func parseToken(ctx context.Context, token string, secret string, now time.Time) (string, bool) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) != 2 {
|
||||
return "", false
|
||||
}
|
||||
expected := signBytes([]byte(parts[0]), secret)
|
||||
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil || !hmac.Equal(expected, actual) {
|
||||
return "", false
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var payload tokenPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return "", false
|
||||
}
|
||||
if payload.Subject == "" || payload.Expires <= now.Unix() {
|
||||
return "", false
|
||||
}
|
||||
if payload.Type != "" && payload.Type != "access" {
|
||||
return "", false
|
||||
}
|
||||
return payload.Subject, true
|
||||
}
|
||||
|
||||
func signWechatBindingToken(identity WechatIdentity, secret string, ttl time.Duration, now time.Time) (string, int64, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 5 * time.Minute
|
||||
}
|
||||
if strings.TrimSpace(identity.OpenID) == "" {
|
||||
return "", 0, fmt.Errorf("%w: wechat identity is incomplete", ErrInvalidCredentials)
|
||||
}
|
||||
payload := tokenPayload{
|
||||
Type: "wechat_binding",
|
||||
OpenID: strings.TrimSpace(identity.OpenID),
|
||||
UnionID: strings.TrimSpace(identity.UnionID),
|
||||
Issued: now.Unix(),
|
||||
Expires: now.Add(ttl).Unix(),
|
||||
}
|
||||
token, err := signPayload(payload, secret)
|
||||
return token, int64(ttl.Seconds()), err
|
||||
}
|
||||
|
||||
func parseWechatBindingToken(ctx context.Context, token string, secret string, now time.Time) (WechatIdentity, bool) {
|
||||
payload, ok := parsePayload(ctx, token, secret)
|
||||
if !ok {
|
||||
return WechatIdentity{}, false
|
||||
}
|
||||
if payload.Type != "wechat_binding" || payload.OpenID == "" || payload.Expires <= now.Unix() {
|
||||
return WechatIdentity{}, false
|
||||
}
|
||||
return WechatIdentity{OpenID: payload.OpenID, UnionID: payload.UnionID}, true
|
||||
}
|
||||
|
||||
func signPayload(payload tokenPayload, secret string) (string, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
encodedPayload := base64.RawURLEncoding.EncodeToString(data)
|
||||
signature := signBytes([]byte(encodedPayload), secret)
|
||||
return encodedPayload + "." + base64.RawURLEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
func parsePayload(ctx context.Context, token string, secret string) (tokenPayload, bool) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return tokenPayload{}, false
|
||||
}
|
||||
parts := strings.Split(strings.TrimSpace(token), ".")
|
||||
if len(parts) != 2 {
|
||||
return tokenPayload{}, false
|
||||
}
|
||||
expected := signBytes([]byte(parts[0]), secret)
|
||||
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil || !hmac.Equal(expected, actual) {
|
||||
return tokenPayload{}, false
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return tokenPayload{}, false
|
||||
}
|
||||
var payload tokenPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return tokenPayload{}, false
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
func signState(secret string, ttl time.Duration, now time.Time) (string, int64, error) {
|
||||
stateID := newID()
|
||||
token, expiresIn, err := signToken(stateID, secret, ttl, now)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return token, expiresIn, nil
|
||||
}
|
||||
|
||||
func verifyState(ctx context.Context, state string, secret string, now time.Time) error {
|
||||
if strings.TrimSpace(state) == "" {
|
||||
return fmt.Errorf("%w: state is required", ErrInvalidCredentials)
|
||||
}
|
||||
if _, ok := parseToken(ctx, state, secret, now); !ok {
|
||||
return fmt.Errorf("%w: state is invalid", ErrInvalidCredentials)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hashVerificationCode(code string, target string, secret string) string {
|
||||
value := strings.TrimSpace(target) + ":" + strings.TrimSpace(code)
|
||||
return base64.RawURLEncoding.EncodeToString(signBytes([]byte(value), secret))
|
||||
}
|
||||
|
||||
func signBytes(data []byte, secret string) []byte {
|
||||
secret = strings.TrimSpace(secret)
|
||||
if secret == "" {
|
||||
secret = "local-dev-change-me"
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write(data)
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func bearerToken(header string) (string, bool) {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" {
|
||||
return "", false
|
||||
}
|
||||
parts := strings.Fields(header)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return "", false
|
||||
}
|
||||
return parts[1], true
|
||||
}
|
||||
|
||||
func normalizeTokenErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrInvalidCredentials, err)
|
||||
}
|
||||
Reference in New Issue
Block a user