Files
rdp-proxy/backend/internal/modules/auth/service.go
T
m 20d361a886
build / backend (push) Has been cancelled
build / node-agent (push) Has been cancelled
build / worker (push) Has been cancelled
рабочий вариант, но скороть 10 МБит
2026-05-22 21:46:49 +03:00

507 lines
15 KiB
Go

package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"github.com/example/remote-access-platform/backend/internal/platform/authority"
"github.com/example/remote-access-platform/backend/internal/platform/module"
postgresplatform "github.com/example/remote-access-platform/backend/internal/platform/postgres"
)
type Service struct {
cfg module.Config
store Store
db postgresplatform.DBTX
transactor Transactor
tokenManager *TokenManager
authority *authority.Verifier
now func() time.Time
}
func NewService(deps module.Dependencies, store Store, transactor Transactor, verifiers ...*authority.Verifier) *Service {
var authorityVerifier *authority.Verifier
if len(verifiers) > 0 {
authorityVerifier = verifiers[0]
} else if verifier, err := authority.NewVerifier(deps.Config.Installation); err == nil {
authorityVerifier = verifier
}
service := &Service{
cfg: deps.Config,
store: store,
transactor: transactor,
tokenManager: NewTokenManager(TokenConfig{
Issuer: deps.Config.Auth.Issuer,
AccessTokenSecret: deps.Config.Auth.AccessTokenSecret,
RefreshHashSecret: deps.Config.Auth.RefreshHashSecret,
AccessTokenTTL: deps.Config.Auth.AccessTokenTTL,
RefreshTokenTTL: deps.Config.Auth.RefreshTokenTTL,
}),
authority: authorityVerifier,
now: time.Now,
}
if postgresStore, ok := store.(*postgresStore); ok {
service.db = postgresStore.db
}
return service
}
func (s *Service) Login(ctx context.Context, cmd LoginCommand) (*AuthResult, error) {
user, err := s.store.Users().GetByEmail(ctx, cmd.Email)
if err != nil {
return nil, err
}
if user == nil {
return nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(cmd.Password)); err != nil {
return nil, ErrInvalidCredentials
}
var result AuthResult
now := s.now().UTC()
if err := s.transactor.WithinTransaction(ctx, func(store Store) error {
device, err := store.Devices().Upsert(ctx, UpsertDeviceParams{
UserID: user.ID,
Fingerprint: cmd.DeviceFingerprint,
Label: cmd.DeviceLabel,
TrustRequested: cmd.TrustDevice,
SeenAt: now,
})
if err != nil {
return err
}
if device.TrustStatus == DeviceTrustStatusRevoked {
return ErrDeviceRevoked
}
authSessionID := uuid.NewString()
refreshToken, refreshHash, refreshExpiresAt, err := s.tokenManager.IssueRefreshToken(authSessionID, now)
if err != nil {
return err
}
accessToken, accessExpiresAt, err := s.tokenManager.IssueAccessToken(user.ID, authSessionID, device.ID, now)
if err != nil {
return err
}
session := AuthSession{
ID: authSessionID,
UserID: user.ID,
DeviceID: device.ID,
RefreshTokenHash: refreshHash,
RefreshExpiresAt: refreshExpiresAt,
CreatedAt: now,
UpdatedAt: now,
LastSeenAt: &now,
}
if err := store.AuthSessions().Create(ctx, session); err != nil {
return err
}
result = AuthResult{
User: *user,
Device: *device,
AuthSession: session,
Tokens: TokenPair{
AccessToken: accessToken,
AccessTokenExpiresAt: accessExpiresAt,
RefreshToken: refreshToken,
RefreshTokenExpiresAt: refreshExpiresAt,
},
}
return nil
}); err != nil {
return nil, err
}
return &result, nil
}
func (s *Service) ListUsers(ctx context.Context, actorUserID string) ([]User, error) {
if err := s.ensurePlatformAdmin(ctx, actorUserID); err != nil {
return nil, err
}
return s.store.Users().List(ctx)
}
func (s *Service) CreateUser(ctx context.Context, cmd CreateUserCommand) (*User, error) {
if err := s.ensurePlatformAdmin(ctx, cmd.ActorUserID); err != nil {
return nil, err
}
email := strings.ToLower(strings.TrimSpace(cmd.Email))
password := strings.TrimSpace(cmd.Password)
role := strings.TrimSpace(cmd.PlatformRole)
if role == "" {
role = "user"
}
if email == "" || !strings.Contains(email, "@") || len(password) < 8 {
return nil, ErrInvalidBootstrapOwner
}
if role != "user" && role != authority.PlatformRoleAdmin && role != authority.PlatformRoleRecoveryAdmin {
return nil, ErrInvalidBootstrapOwner
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash user password: %w", err)
}
now := s.now().UTC()
return s.store.Users().Create(ctx, User{
Email: email,
PasswordHash: string(passwordHash),
MFAEnabled: false,
PlatformRole: role,
CreatedAt: now,
UpdatedAt: now,
})
}
func (s *Service) Refresh(ctx context.Context, cmd RefreshCommand) (*AuthResult, error) {
authSessionID, err := s.tokenManager.ParseRefreshToken(cmd.RefreshToken)
if err != nil {
return nil, err
}
var result AuthResult
now := s.now().UTC()
if err := s.transactor.WithinTransaction(ctx, func(store Store) error {
session, err := store.AuthSessions().GetByIDForUpdate(ctx, authSessionID)
if err != nil {
return err
}
if session == nil {
return ErrInvalidRefreshToken
}
if session.RevokedAt != nil {
return ErrAuthSessionRevoked
}
if now.After(session.RefreshExpiresAt) {
if revokeErr := store.AuthSessions().Revoke(ctx, RevokeAuthSessionParams{
AuthSessionID: session.ID,
UserID: session.UserID,
Reason: "refresh_token_expired",
RevokedAt: now,
}); revokeErr != nil {
return revokeErr
}
return ErrInvalidRefreshToken
}
expectedHash := s.tokenManager.HashRefreshToken(cmd.RefreshToken)
if expectedHash != session.RefreshTokenHash {
if revokeErr := store.AuthSessions().Revoke(ctx, RevokeAuthSessionParams{
AuthSessionID: session.ID,
UserID: session.UserID,
Reason: "refresh_rotation_reuse_detected",
RevokedAt: now,
}); revokeErr != nil {
return revokeErr
}
return ErrInvalidRefreshToken
}
user, err := store.Users().GetByID(ctx, session.UserID)
if err != nil {
return err
}
if user == nil {
return ErrInvalidCredentials
}
device, err := store.Devices().GetByIDForUser(ctx, session.UserID, session.DeviceID)
if err != nil {
return err
}
if device == nil {
return ErrTrustedDeviceMissing
}
if device.TrustStatus == DeviceTrustStatusRevoked {
return ErrDeviceRevoked
}
refreshToken, refreshHash, refreshExpiresAt, err := s.tokenManager.IssueRefreshToken(session.ID, now)
if err != nil {
return err
}
accessToken, accessExpiresAt, err := s.tokenManager.IssueAccessToken(user.ID, session.ID, device.ID, now)
if err != nil {
return err
}
if err := store.AuthSessions().Rotate(ctx, RotateAuthSessionParams{
AuthSessionID: session.ID,
RefreshTokenHash: refreshHash,
RefreshExpiresAt: refreshExpiresAt,
LastSeenAt: now,
LastRotatedAt: now,
}); err != nil {
return err
}
result = AuthResult{
User: *user,
Device: *device,
AuthSession: AuthSession{
ID: session.ID,
UserID: session.UserID,
DeviceID: session.DeviceID,
RefreshTokenHash: refreshHash,
RefreshExpiresAt: refreshExpiresAt,
LastSeenAt: &now,
LastRotatedAt: &now,
},
Tokens: TokenPair{
AccessToken: accessToken,
AccessTokenExpiresAt: accessExpiresAt,
RefreshToken: refreshToken,
RefreshTokenExpiresAt: refreshExpiresAt,
},
}
return nil
}); err != nil {
return nil, err
}
return &result, nil
}
func (s *Service) InstallationStatus(ctx context.Context) (*InstallationStatus, error) {
record, err := s.store.Installation().GetStatus(ctx)
if err != nil {
return nil, err
}
return s.installationStatusFromRecord(record), nil
}
func (s *Service) BootstrapOwner(ctx context.Context, cmd BootstrapOwnerCommand) (*BootstrapOwnerResult, error) {
email := strings.ToLower(strings.TrimSpace(cmd.Email))
password := strings.TrimSpace(cmd.Password)
if email == "" || !strings.Contains(email, "@") || len(password) < 12 {
return nil, ErrInvalidBootstrapOwner
}
now := s.now().UTC()
role := authority.PlatformRoleAdmin
installID := ""
grantSource := "installation_activation"
rootFingerprint := ""
activationPayload := cmd.ActivationPayload
activationSignature := strings.TrimSpace(cmd.ActivationSignature)
var expiresAt *time.Time
if s.strictAuthority() {
if len(activationPayload) == 0 || activationSignature == "" {
return nil, ErrInstallationActivationRequired
}
activation, err := s.authority.VerifyActivation(activationPayload, activationSignature)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidInstallationActivation, err)
}
if !strings.EqualFold(activation.OwnerEmail, email) {
return nil, ErrInvalidInstallationActivation
}
role = activation.PlatformRole
installID = activation.InstallID
expiresAt = activation.ExpiresAt
rootFingerprint = s.authority.RootFingerprint()
} else {
if s.authority == nil || !s.authority.AllowInsecureBootstrap() {
return nil, ErrInsecureBootstrapDisabled
}
installID = uuid.NewString()
grantSource = "dev_insecure"
rootFingerprint = "dev-insecure"
devPayload, err := json.Marshal(authority.ActivationPayload{
SchemaVersion: authority.ActivationSchemaVersion,
InstallID: installID,
OwnerEmail: email,
PlatformRole: role,
IssuedAt: now,
Environment: s.cfg.App.Env,
})
if err != nil {
return nil, err
}
activationPayload = json.RawMessage(devPayload)
activationSignature = "dev-insecure"
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash bootstrap owner password: %w", err)
}
var user *User
if err := s.transactor.WithinTransaction(ctx, func(store Store) error {
created, err := store.Installation().BootstrapOwner(ctx, BootstrapOwnerParams{
Email: email,
PasswordHash: string(passwordHash),
Role: role,
InstallID: installID,
ProductRootKeyFingerprint: rootFingerprint,
ActivationPayload: activationPayload,
ActivationSignature: activationSignature,
GrantSource: grantSource,
ExpiresAt: expiresAt,
Now: now,
})
if err != nil {
return err
}
user = created
return nil
}); err != nil {
return nil, err
}
status, err := s.InstallationStatus(ctx)
if err != nil {
return nil, err
}
return &BootstrapOwnerResult{
Installation: *status,
User: *user,
PlatformRole: role,
}, nil
}
func (s *Service) RevokeAuthSession(ctx context.Context, cmd RevokeAuthSessionCommand) error {
return s.transactor.WithinTransaction(ctx, func(store Store) error {
session, err := store.AuthSessions().GetByIDForUpdate(ctx, cmd.AuthSessionID)
if err != nil {
return err
}
if session == nil || session.UserID != cmd.UserID {
return ErrAuthSessionNotFound
}
return store.AuthSessions().Revoke(ctx, RevokeAuthSessionParams{
AuthSessionID: cmd.AuthSessionID,
UserID: cmd.UserID,
Reason: cmd.Reason,
RevokedAt: s.now().UTC(),
})
})
}
func (s *Service) RevokeTrustedDevice(ctx context.Context, cmd RevokeDeviceCommand) error {
return s.transactor.WithinTransaction(ctx, func(store Store) error {
device, err := store.Devices().GetByIDForUser(ctx, cmd.UserID, cmd.DeviceID)
if err != nil {
return err
}
if device == nil {
return ErrTrustedDeviceMissing
}
if device.TrustStatus != DeviceTrustStatusTrusted {
return ErrDeviceNotTrusted
}
now := s.now().UTC()
if err := store.Devices().Revoke(ctx, RevokeDeviceParams{
UserID: cmd.UserID,
DeviceID: cmd.DeviceID,
Reason: cmd.Reason,
RevokedAt: now,
}); err != nil {
return err
}
return store.AuthSessions().RevokeByDevice(ctx, cmd.UserID, cmd.DeviceID, "device_revoked:"+cmd.Reason, now)
})
}
func (s *Service) ListTrustedDevices(ctx context.Context, userID string) ([]Device, error) {
return s.store.Devices().ListTrustedByUser(ctx, userID)
}
func (s *Service) MapError(err error) (int, string) {
switch {
case err == nil:
return 0, ""
case errors.Is(err, ErrInvalidCredentials):
return 401, "invalid credentials"
case errors.Is(err, ErrInvalidRefreshToken):
return 401, "invalid refresh token"
case errors.Is(err, ErrAuthSessionRevoked):
return 401, "auth session revoked"
case errors.Is(err, ErrDeviceRevoked):
return 403, "device revoked"
case errors.Is(err, ErrDeviceNotTrusted):
return 409, "device is not trusted"
case errors.Is(err, ErrAuthSessionNotFound), errors.Is(err, ErrTrustedDeviceMissing):
return 404, err.Error()
case errors.Is(err, ErrInstallationActivationRequired), errors.Is(err, ErrInvalidInstallationActivation), errors.Is(err, ErrInvalidBootstrapOwner):
return 400, err.Error()
case errors.Is(err, ErrInsecureBootstrapDisabled):
return 403, err.Error()
case errors.Is(err, ErrInstallationAlreadyBootstrapped):
return 409, err.Error()
default:
return 500, fmt.Sprintf("internal error: %v", err)
}
}
func (s *Service) installationStatusFromRecord(record *InstallationAuthorityState) *InstallationStatus {
if record == nil {
record = &InstallationAuthorityState{AuthorityState: "unbootstrapped"}
}
mode := authority.ModeCompat
strict := false
rootFingerprint := ""
insecureAllowed := false
if s.authority != nil {
mode = s.authority.Mode()
strict = s.authority.Strict()
rootFingerprint = s.authority.RootFingerprint()
insecureAllowed = s.authority.AllowInsecureBootstrap()
}
if record.ProductRootFingerprint != "" {
rootFingerprint = record.ProductRootFingerprint
}
return &InstallationStatus{
Bootstrapped: record.Bootstrapped,
AuthorityState: record.AuthorityState,
InstallID: record.InstallID,
BootstrappedOwnerEmail: record.BootstrappedOwnerEmail,
BootstrappedAt: record.BootstrappedAt,
AuthorityMode: mode,
StrictAuthority: strict,
RootFingerprint: rootFingerprint,
InsecureBootstrapAllowed: insecureAllowed,
}
}
func (s *Service) strictAuthority() bool {
return s.authority != nil && s.authority.Strict()
}
func (s *Service) ensurePlatformAdmin(ctx context.Context, actorUserID string) error {
if actorUserID == "" {
return ErrInvalidCredentials
}
role := authority.PlatformRoleUser
if s.db != nil {
effectiveRole, err := authority.EffectivePlatformRole(ctx, s.db, s.authority, actorUserID)
if err != nil {
return err
}
role = effectiveRole
} else if user, err := s.store.Users().GetByID(ctx, actorUserID); err != nil {
return err
} else if user != nil && user.PlatformRole != "" {
role = user.PlatformRole
}
if role != authority.PlatformRoleAdmin && role != authority.PlatformRoleRecoveryAdmin {
return ErrDeviceRevoked
}
return nil
}