Initial project snapshot
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user