Files
root 44406b72db 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>
2026-07-07 23:15:37 +08:00

197 lines
5.5 KiB
Go

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)
}