507 lines
15 KiB
Go
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.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()
|
|
}
|
|
|
|
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
|
|
}
|