Files
2026-06-11 20:34:13 +02:00

268 lines
8.6 KiB
Go

package service
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"github.com/go-errors/errors"
"github.com/google/uuid"
"github.com/oapi-codegen/nullable"
"github.com/phishingclub/phishingclub/data"
"github.com/phishingclub/phishingclub/errs"
"github.com/phishingclub/phishingclub/model"
"github.com/phishingclub/phishingclub/repository"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// CompanyScimConfig is the service for SCIM configuration per company
type CompanyScimConfig struct {
Common
CompanyScimConfigRepository *repository.CompanyScimConfig
}
// generateToken creates a cryptographically random 32-byte token and returns
// the hex-encoded plain token and its 8-character prefix
func generateScimToken() (plain string, prefix string, err error) {
buf := make([]byte, 32)
if _, err = rand.Read(buf); err != nil {
return "", "", fmt.Errorf("failed to generate random token: %w", err)
}
plain = hex.EncodeToString(buf)
prefix = plain[:8]
return plain, prefix, nil
}
// hashScimToken bcrypt-hashes the plain token with cost 12
func hashScimToken(plain string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), 12)
if err != nil {
return "", fmt.Errorf("failed to hash token: %w", err)
}
return string(hash), nil
}
// GetByCompanyID returns the SCIM config for the given company
func (s *CompanyScimConfig) GetByCompanyID(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
) (*model.CompanyScimConfig, error) {
ae := NewAuditEvent("CompanyScimConfig.GetByCompanyID", session)
ae.Details["companyID"] = companyID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// get
config, err := s.CompanyScimConfigRepository.GetByCompanyID(ctx, companyID)
if err != nil {
s.Logger.Errorw("failed to get scim config by company id", "error", err)
return nil, errs.Wrap(err)
}
// no audit on read
return config, nil
}
// Upsert creates or updates the SCIM configuration for the given company.
// when creating, a new token is generated and returned in plain text (shown once).
// when updating, the token is NOT rotated.
func (s *CompanyScimConfig) Upsert(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
enabled bool,
) (*model.CompanyScimConfig, error) {
ae := NewAuditEvent("CompanyScimConfig.Upsert", session)
ae.Details["companyID"] = companyID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// check if a config already exists for this company
existing, err := s.CompanyScimConfigRepository.GetByCompanyID(ctx, companyID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Logger.Errorw("failed to look up existing scim config", "error", err)
return nil, errs.Wrap(err)
}
if existing != nil {
// update enabled and linked group only; do not regenerate the token
existing.Enabled = enabled
existingID, idErr := existing.ID.Get()
if idErr != nil {
s.Logger.Errorw("scim config has no id", "error", idErr)
return nil, errs.Wrap(idErr)
}
if err := s.CompanyScimConfigRepository.UpdateByID(ctx, &existingID, existing); err != nil {
s.Logger.Errorw("failed to update scim config", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = existingID.String()
ae.Details["action"] = "update"
s.AuditLogAuthorized(ae)
return existing, nil
}
// create: generate a fresh token
plain, prefix, err := generateScimToken()
if err != nil {
s.Logger.Errorw("failed to generate scim token", "error", err)
return nil, errs.Wrap(err)
}
tokenHash, err := hashScimToken(plain)
if err != nil {
s.Logger.Errorw("failed to hash scim token", "error", err)
return nil, errs.Wrap(err)
}
config := &model.CompanyScimConfig{
CompanyID: nullable.NewNullableWithValue(*companyID),
Enabled: enabled,
TokenPrefix: nullable.NewNullableWithValue(prefix),
}
id, err := s.CompanyScimConfigRepository.Insert(ctx, config, tokenHash)
if err != nil {
s.Logger.Errorw("failed to insert scim config", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = id.String()
ae.Details["action"] = "insert"
s.AuditLogAuthorized(ae)
// load the persisted record and attach the plain token for one-time display
created, err := s.CompanyScimConfigRepository.GetByID(ctx, id)
if err != nil {
s.Logger.Errorw("failed to fetch newly inserted scim config", "error", err)
return nil, errs.Wrap(err)
}
// set plain token on model for one-time display; it is never persisted
created.Token = plain
return created, nil
}
// RotateToken generates a new token for the existing SCIM config identified by
// companyID. the plain token is returned once so the caller can present it to
// the user; only the hash is persisted.
func (s *CompanyScimConfig) RotateToken(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
) (*model.CompanyScimConfig, error) {
ae := NewAuditEvent("CompanyScimConfig.RotateToken", session)
ae.Details["companyID"] = companyID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
s.LogAuthError(err)
return nil, errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return nil, errs.ErrAuthorizationFailed
}
// fetch existing config
config, err := s.CompanyScimConfigRepository.GetByCompanyID(ctx, companyID)
if err != nil {
s.Logger.Errorw("failed to get scim config for token rotation", "error", err)
return nil, errs.Wrap(err)
}
configID, idErr := config.ID.Get()
if idErr != nil {
s.Logger.Errorw("scim config has no id", "error", idErr)
return nil, errs.Wrap(idErr)
}
// generate a new token
plain, prefix, err := generateScimToken()
if err != nil {
s.Logger.Errorw("failed to generate new scim token", "error", err)
return nil, errs.Wrap(err)
}
tokenHash, err := hashScimToken(plain)
if err != nil {
s.Logger.Errorw("failed to hash new scim token", "error", err)
return nil, errs.Wrap(err)
}
if err := s.CompanyScimConfigRepository.UpdateTokenByID(ctx, &configID, tokenHash, prefix); err != nil {
s.Logger.Errorw("failed to update scim token", "error", err)
return nil, errs.Wrap(err)
}
ae.Details["id"] = configID.String()
s.AuditLogAuthorized(ae)
// reload and return with plain token set for one-time display
updated, err := s.CompanyScimConfigRepository.GetByID(ctx, &configID)
if err != nil {
s.Logger.Errorw("failed to fetch scim config after token rotation", "error", err)
return nil, errs.Wrap(err)
}
// set plain token on model for one-time display; it is never persisted
updated.Token = plain
return updated, nil
}
// DeleteByCompanyID removes the SCIM configuration for the given company
func (s *CompanyScimConfig) DeleteByCompanyID(
ctx context.Context,
session *model.Session,
companyID *uuid.UUID,
) error {
ae := NewAuditEvent("CompanyScimConfig.DeleteByCompanyID", session)
ae.Details["companyID"] = companyID.String()
// check permissions
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
if err != nil {
s.LogAuthError(err)
return errs.Wrap(err)
}
if !isAuthorized {
s.AuditLogNotAuthorized(ae)
return errs.ErrAuthorizationFailed
}
// delete
if err := s.CompanyScimConfigRepository.DeleteByCompanyID(ctx, companyID); err != nil {
s.Logger.Errorw("failed to delete scim config", "error", err)
return errs.Wrap(err)
}
s.AuditLogAuthorized(ae)
return nil
}
// VerifyToken checks whether the supplied plain token matches the stored bcrypt
// hash for the given company. no session or permission check is performed
// because this is called by the inbound SCIM handler during bearer-token auth.
func (s *CompanyScimConfig) VerifyToken(
ctx context.Context,
companyID *uuid.UUID,
plainToken string,
) (bool, *model.CompanyScimConfig, error) {
// fetch config and token hash in a single round-trip
config, tokenHash, err := s.CompanyScimConfigRepository.GetWithTokenHashByCompanyID(ctx, companyID)
if err != nil {
s.Logger.Errorw("failed to get scim config for token verification", "error", err)
return false, nil, errs.Wrap(err)
}
if err := bcrypt.CompareHashAndPassword([]byte(tokenHash), []byte(plainToken)); err != nil {
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return false, config, nil
}
s.Logger.Errorw("failed to compare scim token hash", "error", err)
return false, config, errs.Wrap(err)
}
return true, config, nil
}