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>
197 lines
5.5 KiB
Go
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)
|
|
}
|