mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-03 19:07:58 +02:00
61ecc4c505
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
268 lines
8.6 KiB
Go
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
|
|
}
|