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[:]) }