Checks / verify (push) Failing after 9m52s
统一单入口 Go 工程与前端依赖目录,增加后台供应商配置、候选审核、计划预算、成交冲正、持仓复盘和站内通知。接入策略版本、Python 回测、可配置 A 股公开行情及 zap 文件轮转日志,补充迁移、契约与验证。
150 lines
5.1 KiB
Go
150 lines
5.1 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
|
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
|
)
|
|
|
|
type AuthRepository interface {
|
|
CreateUser(context.Context, model.User, model.Credential) (model.User, error)
|
|
FindCredential(context.Context, string) (model.User, model.Credential, error)
|
|
CreateSession(context.Context, model.Session, string) error
|
|
FindSessionUser(context.Context, string, string, time.Time) (model.User, error)
|
|
RevokeSession(context.Context, string, string, time.Time) error
|
|
ListUsers(context.Context, int) ([]model.User, error)
|
|
}
|
|
|
|
type AuthService struct {
|
|
repo AuthRepository
|
|
now func() time.Time
|
|
dummyHash []byte
|
|
}
|
|
|
|
func NewAuthService(repo AuthRepository) (*AuthService, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte("invalid-user-timing-placeholder"), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("initialize password verifier: %w", err)
|
|
}
|
|
return &AuthService{repo: repo, now: time.Now, dummyHash: hash}, nil
|
|
}
|
|
|
|
var loginPattern = regexp.MustCompile(`^[a-z0-9_]{3,64}$`)
|
|
|
|
// ProvisionUser is called by a local CLI. Public registration cannot choose ADMIN.
|
|
func (s *AuthService) ProvisionUser(ctx context.Context, login, password, name, role string) (model.User, error) {
|
|
login = strings.ToLower(strings.TrimSpace(login))
|
|
name = strings.TrimSpace(name)
|
|
minimumPasswordLength := 12
|
|
if role == "ADMIN" {
|
|
minimumPasswordLength = 8
|
|
}
|
|
if !loginPattern.MatchString(login) || len(password) < minimumPasswordLength || len(password) > 72 || utf8.RuneCountInString(name) > 80 || name == "" || (role != "USER" && role != "ADMIN") {
|
|
return model.User{}, apperror.ErrInvalidInput
|
|
}
|
|
if s.repo == nil {
|
|
return model.User{}, apperror.ErrUnavailable
|
|
}
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
return model.User{}, fmt.Errorf("hash password: %w", err)
|
|
}
|
|
user := model.User{ID: uuid.NewString(), DisplayName: name, Status: "ACTIVE", Role: role}
|
|
credential := model.Credential{ID: uuid.NewString(), UserID: user.ID, LoginName: login, PasswordHash: string(hash)}
|
|
return s.repo.CreateUser(ctx, user, credential)
|
|
}
|
|
|
|
type LoginResult struct {
|
|
User model.User
|
|
Token string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
func (s *AuthService) Login(ctx context.Context, login, password, audience string) (LoginResult, error) {
|
|
if audience != "admin" && audience != "app" {
|
|
return LoginResult{}, apperror.ErrInvalidInput
|
|
}
|
|
if s.repo == nil {
|
|
return LoginResult{}, apperror.ErrUnavailable
|
|
}
|
|
login = strings.ToLower(strings.TrimSpace(login))
|
|
if !loginPattern.MatchString(login) || len(password) > 72 || password == "" {
|
|
return LoginResult{}, apperror.ErrUnauthenticated
|
|
}
|
|
user, credential, err := s.repo.FindCredential(ctx, login)
|
|
if err != nil {
|
|
if errors.Is(err, apperror.ErrNotFound) {
|
|
_ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password))
|
|
return LoginResult{}, apperror.ErrUnauthenticated
|
|
}
|
|
return LoginResult{}, err
|
|
}
|
|
if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(password)) != nil || user.Status != "ACTIVE" || (audience == "admin" && user.Role != "ADMIN") || (audience == "app" && user.Role != "USER") {
|
|
return LoginResult{}, apperror.ErrUnauthenticated
|
|
}
|
|
raw := make([]byte, 32)
|
|
if _, err := rand.Read(raw); err != nil {
|
|
return LoginResult{}, fmt.Errorf("generate session token: %w", err)
|
|
}
|
|
token := base64.RawURLEncoding.EncodeToString(raw)
|
|
expires := s.now().UTC().Add(8 * time.Hour)
|
|
session := model.Session{ID: uuid.NewString(), UserID: user.ID, TokenHash: TokenHash(token), Audience: audience, ExpiresAt: expires}
|
|
if err := s.repo.CreateSession(ctx, session, credential.PasswordHash); err != nil {
|
|
return LoginResult{}, err
|
|
}
|
|
return LoginResult{User: user, Token: token, ExpiresAt: expires}, nil
|
|
}
|
|
|
|
func (s *AuthService) Authenticate(ctx context.Context, token, audience string) (model.User, error) {
|
|
if s.repo == nil {
|
|
return model.User{}, apperror.ErrUnavailable
|
|
}
|
|
if len(token) != 43 {
|
|
return model.User{}, apperror.ErrUnauthenticated
|
|
}
|
|
if audience != "admin" && audience != "app" {
|
|
return model.User{}, apperror.ErrUnauthenticated
|
|
}
|
|
user, err := s.repo.FindSessionUser(ctx, TokenHash(token), audience, s.now().UTC())
|
|
if err != nil {
|
|
return model.User{}, err
|
|
}
|
|
if user.Status != "ACTIVE" || audience == "admin" && user.Role != "ADMIN" || audience == "app" && user.Role != "USER" {
|
|
return model.User{}, apperror.ErrUnauthenticated
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (s *AuthService) Logout(ctx context.Context, token, audience string) error {
|
|
if s.repo == nil {
|
|
return apperror.ErrUnavailable
|
|
}
|
|
return s.repo.RevokeSession(ctx, TokenHash(token), audience, s.now().UTC())
|
|
}
|
|
|
|
func (s *AuthService) ListUsers(ctx context.Context) ([]model.User, error) {
|
|
if s.repo == nil {
|
|
return nil, apperror.ErrUnavailable
|
|
}
|
|
return s.repo.ListUsers(ctx, 100)
|
|
}
|
|
|
|
func TokenHash(token string) string {
|
|
sum := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|