@@ -0,0 +1,70 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
)
|
||||
|
||||
type AccountRepository interface {
|
||||
Create(context.Context, model.Account, *model.CashEntry) (model.AccountSummary, error)
|
||||
List(context.Context, string) ([]model.AccountSummary, error)
|
||||
Get(context.Context, string, string) (model.AccountSummary, error)
|
||||
}
|
||||
|
||||
type AccountService struct{ repo AccountRepository }
|
||||
|
||||
func NewAccountService(repo AccountRepository) *AccountService { return &AccountService{repo: repo} }
|
||||
|
||||
func (s *AccountService) Create(ctx context.Context, userID, mode, name string, initialCash *string) (model.AccountSummary, error) {
|
||||
if s.repo == nil {
|
||||
return model.AccountSummary{}, apperror.ErrUnavailable
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || utf8.RuneCountInString(name) > 80 {
|
||||
return model.AccountSummary{}, apperror.ErrInvalidInput
|
||||
}
|
||||
account := model.Account{ID: uuid.NewString(), UserID: userID, Mode: mode, Name: name, Currency: "CNY", LedgerStatus: "INCOMPLETE"}
|
||||
var entry *model.CashEntry
|
||||
switch mode {
|
||||
case "simulation":
|
||||
if initialCash == nil {
|
||||
return model.AccountSummary{}, apperror.ErrInvalidInput
|
||||
}
|
||||
amount, err := model.ParseMoney(*initialCash)
|
||||
if err != nil {
|
||||
return model.AccountSummary{}, err
|
||||
}
|
||||
account.LedgerStatus = "COMPLETE"
|
||||
entry = &model.CashEntry{ID: uuid.NewString(), AccountID: account.ID, EntryType: "INITIAL", AmountMinor: amount}
|
||||
case "manual_real":
|
||||
if initialCash != nil {
|
||||
return model.AccountSummary{}, apperror.ErrInvalidInput
|
||||
}
|
||||
default:
|
||||
return model.AccountSummary{}, apperror.ErrInvalidInput
|
||||
}
|
||||
return s.repo.Create(ctx, account, entry)
|
||||
}
|
||||
|
||||
func (s *AccountService) List(ctx context.Context, userID string) ([]model.AccountSummary, error) {
|
||||
if s.repo == nil {
|
||||
return nil, apperror.ErrUnavailable
|
||||
}
|
||||
return s.repo.List(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *AccountService) Get(ctx context.Context, userID, accountID string) (model.AccountSummary, error) {
|
||||
if s.repo == nil {
|
||||
return model.AccountSummary{}, apperror.ErrUnavailable
|
||||
}
|
||||
if _, err := uuid.Parse(accountID); err != nil {
|
||||
return model.AccountSummary{}, apperror.ErrNotFound
|
||||
}
|
||||
return s.repo.Get(ctx, userID, accountID)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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) 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)
|
||||
if !loginPattern.MatchString(login) || len(password) < 12 || 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); 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[:])
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/apperror"
|
||||
"git.sundynix.cn/Blizzard/Veyra/internal/model"
|
||||
)
|
||||
|
||||
type testRepository struct {
|
||||
user model.User
|
||||
credential model.Credential
|
||||
session model.Session
|
||||
createdSessions int
|
||||
}
|
||||
|
||||
func (r *testRepository) CreateUser(_ context.Context, u model.User, c model.Credential) (model.User, error) {
|
||||
r.user = u
|
||||
r.credential = c
|
||||
return u, nil
|
||||
}
|
||||
func (r *testRepository) FindCredential(_ context.Context, login string) (model.User, model.Credential, error) {
|
||||
if login != r.credential.LoginName {
|
||||
return model.User{}, model.Credential{}, apperror.ErrNotFound
|
||||
}
|
||||
return r.user, r.credential, nil
|
||||
}
|
||||
func (r *testRepository) CreateSession(_ context.Context, s model.Session) error {
|
||||
r.session = s
|
||||
r.createdSessions++
|
||||
return nil
|
||||
}
|
||||
func (r *testRepository) FindSessionUser(_ context.Context, _, _ string, _ time.Time) (model.User, error) {
|
||||
return r.user, nil
|
||||
}
|
||||
func (r *testRepository) RevokeSession(_ context.Context, _, _ string, _ time.Time) error { return nil }
|
||||
func (r *testRepository) ListUsers(context.Context, int) ([]model.User, error) {
|
||||
return []model.User{r.user}, nil
|
||||
}
|
||||
|
||||
func TestPasswordAndSessionSecretsAreHashed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := &testRepository{}
|
||||
auth, err := NewAuthService(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
password := "a-development-password"
|
||||
if _, err := auth.ProvisionUser(ctx, " ADMIN ", password, "Admin", "ADMIN"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repo.credential.PasswordHash == password || repo.credential.LoginName != "admin" {
|
||||
t.Fatal("credential handling failed")
|
||||
}
|
||||
result, err := auth.Login(ctx, "admin", password, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Token) != 43 || repo.session.TokenHash == result.Token || repo.session.TokenHash != TokenHash(result.Token) {
|
||||
t.Fatal("raw session secret persisted")
|
||||
}
|
||||
if _, err := auth.Login(ctx, "admin", "incorrect-password", "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("wrong password accepted")
|
||||
}
|
||||
if _, err := auth.Login(ctx, "admin", password, "app"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("admin credential used for app login")
|
||||
}
|
||||
if _, err := auth.Login(ctx, "missing", password, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("missing identity was disclosed")
|
||||
}
|
||||
if repo.createdSessions != 1 {
|
||||
t.Fatal("failed login created a session")
|
||||
}
|
||||
repo.user.Status = "DISABLED"
|
||||
if _, err := auth.Authenticate(ctx, result.Token, "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("disabled user remained authenticated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCannotAuthenticateAsAdmin(t *testing.T) {
|
||||
repo := &testRepository{user: model.User{Status: "ACTIVE", Role: "USER"}}
|
||||
auth, err := NewAuthService(repo)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := auth.Authenticate(context.Background(), "1234567890123456789012345678901234567890123", "admin"); !errors.Is(err, apperror.ErrUnauthenticated) {
|
||||
t.Fatal("user role passed admin authorization")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Probe func(context.Context) error
|
||||
|
||||
type Component struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
type Health struct {
|
||||
Status string `json:"status"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
Components map[string]Component `json:"components"`
|
||||
}
|
||||
type HealthService struct{ postgres, redis Probe }
|
||||
|
||||
func NewHealthService(postgres, redis Probe) *HealthService {
|
||||
return &HealthService{postgres: postgres, redis: redis}
|
||||
}
|
||||
|
||||
func (s *HealthService) Check(ctx context.Context) Health {
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
result := Health{Status: "ready", CheckedAt: time.Now().UTC(), Components: map[string]Component{}}
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
for name, probe := range map[string]Probe{"postgresql": s.postgres, "redis": s.redis} {
|
||||
wg.Go(func() {
|
||||
status := "unavailable"
|
||||
if probe == nil {
|
||||
status = "not_configured"
|
||||
} else if probe(ctx) == nil {
|
||||
status = "ready"
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
result.Components[name] = Component{Status: status}
|
||||
if status != "ready" {
|
||||
result.Status = "not_ready"
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user