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" ) type Service struct { cfg module.Config store Store 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 } return &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, } } 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) 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.ModeLegacy 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() }