Initial project snapshot
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package auth
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
||||
ErrAuthSessionRevoked = errors.New("auth session revoked")
|
||||
ErrDeviceRevoked = errors.New("device revoked")
|
||||
ErrDeviceNotTrusted = errors.New("device not trusted")
|
||||
ErrAuthSessionNotFound = errors.New("auth session not found")
|
||||
ErrTrustedDeviceMissing = errors.New("trusted device not found")
|
||||
|
||||
ErrInstallationAlreadyBootstrapped = errors.New("installation is already bootstrapped")
|
||||
ErrInstallationActivationRequired = errors.New("signed installation activation is required")
|
||||
ErrInvalidInstallationActivation = errors.New("invalid installation activation")
|
||||
ErrInsecureBootstrapDisabled = errors.New("insecure installation bootstrap is disabled")
|
||||
ErrInvalidBootstrapOwner = errors.New("invalid bootstrap owner")
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DeviceTrustStatus string
|
||||
|
||||
const (
|
||||
DeviceTrustStatusPending DeviceTrustStatus = "pending"
|
||||
DeviceTrustStatusTrusted DeviceTrustStatus = "trusted"
|
||||
DeviceTrustStatusRevoked DeviceTrustStatus = "revoked"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
MFAEnabled bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string
|
||||
UserID string
|
||||
Fingerprint string
|
||||
Label string
|
||||
TrustStatus DeviceTrustStatus
|
||||
TrustedAt *time.Time
|
||||
LastSeenAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
RevokedReason *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AuthSession struct {
|
||||
ID string
|
||||
UserID string
|
||||
DeviceID string
|
||||
RefreshTokenHash string
|
||||
RefreshExpiresAt time.Time
|
||||
LastSeenAt *time.Time
|
||||
LastRotatedAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
RevokedReason *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type LoginCommand struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
DeviceFingerprint string `json:"device_fingerprint"`
|
||||
DeviceLabel string `json:"device_label"`
|
||||
TrustDevice bool `json:"trust_device"`
|
||||
}
|
||||
|
||||
type RefreshCommand struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type BootstrapOwnerCommand struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
ActivationPayload json.RawMessage `json:"activation_payload"`
|
||||
ActivationSignature string `json:"activation_signature"`
|
||||
}
|
||||
|
||||
type RevokeAuthSessionCommand struct {
|
||||
UserID string `json:"user_id"`
|
||||
AuthSessionID string `json:"auth_session_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type RevokeDeviceCommand struct {
|
||||
UserID string `json:"user_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
AccessTokenExpiresAt time.Time `json:"access_token_expires_at"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
RefreshTokenExpiresAt time.Time `json:"refresh_token_expires_at"`
|
||||
}
|
||||
|
||||
type AuthResult struct {
|
||||
User User `json:"user"`
|
||||
Device Device `json:"device"`
|
||||
AuthSession AuthSession `json:"auth_session"`
|
||||
Tokens TokenPair `json:"tokens"`
|
||||
}
|
||||
|
||||
type InstallationStatus struct {
|
||||
Bootstrapped bool `json:"bootstrapped"`
|
||||
AuthorityState string `json:"authority_state"`
|
||||
InstallID string `json:"install_id,omitempty"`
|
||||
BootstrappedOwnerEmail string `json:"bootstrapped_owner_email,omitempty"`
|
||||
BootstrappedAt *time.Time `json:"bootstrapped_at,omitempty"`
|
||||
AuthorityMode string `json:"authority_mode"`
|
||||
StrictAuthority bool `json:"strict_authority"`
|
||||
RootFingerprint string `json:"root_fingerprint,omitempty"`
|
||||
InsecureBootstrapAllowed bool `json:"insecure_bootstrap_allowed"`
|
||||
}
|
||||
|
||||
type BootstrapOwnerResult struct {
|
||||
Installation InstallationStatus `json:"installation"`
|
||||
User User `json:"user"`
|
||||
PlatformRole string `json:"platform_role"`
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/httpx"
|
||||
"github.com/example/remote-access-platform/backend/internal/platform/module"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewModule(deps module.Dependencies, service *Service) *Module {
|
||||
return &Module{service: service}
|
||||
}
|
||||
|
||||
func (m *Module) Name() string {
|
||||
return "auth"
|
||||
}
|
||||
|
||||
func (m *Module) RegisterRoutes(router chi.Router) {
|
||||
router.Route("/installation", func(r chi.Router) {
|
||||
r.Get("/status", m.handleInstallationStatus)
|
||||
r.Post("/bootstrap-owner", m.handleBootstrapOwner)
|
||||
})
|
||||
router.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/login", m.handleLogin)
|
||||
r.Post("/refresh", m.handleRefresh)
|
||||
r.Post("/sessions/revoke", m.handleRevokeAuthSession)
|
||||
r.Get("/devices", m.handleTrustedDevices)
|
||||
r.Post("/devices/{deviceID}/revoke", m.handleRevokeTrustedDevice)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) handleInstallationStatus(w http.ResponseWriter, r *http.Request) {
|
||||
status, err := m.service.InstallationStatus(r.Context())
|
||||
if err != nil {
|
||||
statusCode, message := m.service.MapError(err)
|
||||
httpx.WriteError(w, statusCode, message)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"installation": status})
|
||||
}
|
||||
|
||||
func (m *Module) handleBootstrapOwner(w http.ResponseWriter, r *http.Request) {
|
||||
var cmd BootstrapOwnerCommand
|
||||
if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid installation bootstrap payload")
|
||||
return
|
||||
}
|
||||
result, err := m.service.BootstrapOwner(r.Context(), cmd)
|
||||
if err != nil {
|
||||
status, message := m.service.MapError(err)
|
||||
httpx.WriteError(w, status, message)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
|
||||
func (m *Module) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var cmd LoginCommand
|
||||
if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid login payload")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := m.service.Login(r.Context(), cmd)
|
||||
if err != nil {
|
||||
status, message := m.service.MapError(err)
|
||||
httpx.WriteError(w, status, message)
|
||||
return
|
||||
}
|
||||
|
||||
httpx.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (m *Module) handleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
var cmd RefreshCommand
|
||||
if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid refresh payload")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := m.service.Refresh(r.Context(), cmd)
|
||||
if err != nil {
|
||||
status, message := m.service.MapError(err)
|
||||
httpx.WriteError(w, status, message)
|
||||
return
|
||||
}
|
||||
|
||||
httpx.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (m *Module) handleRevokeAuthSession(w http.ResponseWriter, r *http.Request) {
|
||||
var cmd RevokeAuthSessionCommand
|
||||
if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid auth session revoke payload")
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.service.RevokeAuthSession(r.Context(), cmd); err != nil {
|
||||
status, message := m.service.MapError(err)
|
||||
httpx.WriteError(w, status, message)
|
||||
return
|
||||
}
|
||||
|
||||
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{
|
||||
"status": "revoked",
|
||||
"message": httpx.NewMessage(
|
||||
"auth.session.revoked",
|
||||
"status.auth.session.revoked",
|
||||
"Auth session revoked.",
|
||||
nil,
|
||||
"",
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) handleTrustedDevices(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "user_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
devices, err := m.service.ListTrustedDevices(r.Context(), userID)
|
||||
if err != nil {
|
||||
status, message := m.service.MapError(err)
|
||||
httpx.WriteError(w, status, message)
|
||||
return
|
||||
}
|
||||
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"devices": devices,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) handleRevokeTrustedDevice(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
UserID string `json:"user_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid device revoke payload")
|
||||
return
|
||||
}
|
||||
|
||||
err := m.service.RevokeTrustedDevice(r.Context(), RevokeDeviceCommand{
|
||||
UserID: payload.UserID,
|
||||
DeviceID: chi.URLParam(r, "deviceID"),
|
||||
Reason: payload.Reason,
|
||||
})
|
||||
if err != nil {
|
||||
status, message := m.service.MapError(err)
|
||||
httpx.WriteError(w, status, message)
|
||||
return
|
||||
}
|
||||
|
||||
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{
|
||||
"status": "revoked",
|
||||
"message": httpx.NewMessage(
|
||||
"auth.device.revoked",
|
||||
"status.auth.device.revoked",
|
||||
"Trusted device revoked.",
|
||||
nil,
|
||||
"",
|
||||
),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
postgresplatform "github.com/example/remote-access-platform/backend/internal/platform/postgres"
|
||||
)
|
||||
|
||||
type postgresStore struct {
|
||||
db postgresplatform.DBTX
|
||||
}
|
||||
|
||||
type PostgresTransactor struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPostgresStore(pool *pgxpool.Pool) Store {
|
||||
return &postgresStore{db: pool}
|
||||
}
|
||||
|
||||
func NewPostgresTransactor(pool *pgxpool.Pool) *PostgresTransactor {
|
||||
return &PostgresTransactor{pool: pool}
|
||||
}
|
||||
|
||||
func (t *PostgresTransactor) WithinTransaction(ctx context.Context, fn func(store Store) error) error {
|
||||
return postgresplatform.WithTransaction(ctx, t.pool, func(tx pgx.Tx) error {
|
||||
return fn(&postgresStore{db: tx})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *postgresStore) Users() UserRepository {
|
||||
return &postgresUserRepository{db: s.db}
|
||||
}
|
||||
|
||||
func (s *postgresStore) Devices() DeviceRepository {
|
||||
return &postgresDeviceRepository{db: s.db}
|
||||
}
|
||||
|
||||
func (s *postgresStore) AuthSessions() AuthSessionRepository {
|
||||
return &postgresAuthSessionRepository{db: s.db}
|
||||
}
|
||||
|
||||
func (s *postgresStore) Installation() InstallationRepository {
|
||||
return &postgresInstallationRepository{db: s.db}
|
||||
}
|
||||
|
||||
type postgresUserRepository struct {
|
||||
db postgresplatform.DBTX
|
||||
}
|
||||
|
||||
type postgresDeviceRepository struct {
|
||||
db postgresplatform.DBTX
|
||||
}
|
||||
|
||||
type postgresAuthSessionRepository struct {
|
||||
db postgresplatform.DBTX
|
||||
}
|
||||
|
||||
type postgresInstallationRepository struct {
|
||||
db postgresplatform.DBTX
|
||||
}
|
||||
|
||||
func (r *postgresUserRepository) GetByEmail(ctx context.Context, email string) (*User, error) {
|
||||
const query = `
|
||||
SELECT id::text, email, password_hash, mfa_enabled, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
`
|
||||
return scanOptionalUser(r.db.QueryRow(ctx, query, email))
|
||||
}
|
||||
|
||||
func (r *postgresUserRepository) GetByID(ctx context.Context, userID string) (*User, error) {
|
||||
const query = `
|
||||
SELECT id::text, email, password_hash, mfa_enabled, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = $1::uuid
|
||||
`
|
||||
return scanOptionalUser(r.db.QueryRow(ctx, query, userID))
|
||||
}
|
||||
|
||||
func (r *postgresDeviceRepository) Upsert(ctx context.Context, params UpsertDeviceParams) (*Device, error) {
|
||||
const query = `
|
||||
INSERT INTO devices (
|
||||
user_id,
|
||||
device_fingerprint,
|
||||
device_label,
|
||||
trust_status,
|
||||
trusted_at,
|
||||
last_seen_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
$1::uuid,
|
||||
$2,
|
||||
$3,
|
||||
CASE WHEN $4 THEN 'trusted' ELSE 'pending' END,
|
||||
CASE WHEN $4 THEN $5::timestamptz ELSE NULL::timestamptz END,
|
||||
$5::timestamptz,
|
||||
$5::timestamptz,
|
||||
$5::timestamptz
|
||||
)
|
||||
ON CONFLICT (user_id, device_fingerprint) DO UPDATE SET
|
||||
device_label = EXCLUDED.device_label,
|
||||
last_seen_at = EXCLUDED.last_seen_at,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
trust_status = CASE
|
||||
WHEN devices.trust_status = 'revoked' THEN devices.trust_status
|
||||
WHEN devices.trust_status = 'trusted' THEN devices.trust_status
|
||||
WHEN EXCLUDED.trust_status = 'trusted' THEN 'trusted'
|
||||
ELSE devices.trust_status
|
||||
END,
|
||||
trusted_at = CASE
|
||||
WHEN devices.trust_status = 'trusted' THEN devices.trusted_at
|
||||
WHEN EXCLUDED.trust_status = 'trusted' THEN EXCLUDED.trusted_at
|
||||
ELSE devices.trusted_at
|
||||
END
|
||||
RETURNING
|
||||
id::text, user_id::text, device_fingerprint, COALESCE(device_label, ''),
|
||||
trust_status, trusted_at, last_seen_at, revoked_at, revoked_reason, created_at, updated_at
|
||||
`
|
||||
return scanDevice(r.db.QueryRow(ctx, query,
|
||||
params.UserID,
|
||||
params.Fingerprint,
|
||||
params.Label,
|
||||
params.TrustRequested,
|
||||
params.SeenAt,
|
||||
))
|
||||
}
|
||||
|
||||
func (r *postgresDeviceRepository) GetByIDForUser(ctx context.Context, userID, deviceID string) (*Device, error) {
|
||||
const query = `
|
||||
SELECT id::text, user_id::text, device_fingerprint, COALESCE(device_label, ''),
|
||||
trust_status, trusted_at, last_seen_at, revoked_at, revoked_reason, created_at, updated_at
|
||||
FROM devices
|
||||
WHERE id = $1::uuid AND user_id = $2::uuid
|
||||
`
|
||||
device, err := scanDevice(r.db.QueryRow(ctx, query, deviceID, userID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return device, err
|
||||
}
|
||||
|
||||
func (r *postgresDeviceRepository) ListTrustedByUser(ctx context.Context, userID string) ([]Device, error) {
|
||||
const query = `
|
||||
SELECT id::text, user_id::text, device_fingerprint, COALESCE(device_label, ''),
|
||||
trust_status, trusted_at, last_seen_at, revoked_at, revoked_reason, created_at, updated_at
|
||||
FROM devices
|
||||
WHERE user_id = $1::uuid AND trust_status = 'trusted' AND revoked_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
rows, err := r.db.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query trusted devices: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var devices []Device
|
||||
for rows.Next() {
|
||||
device, err := scanDevice(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
devices = append(devices, *device)
|
||||
}
|
||||
return devices, rows.Err()
|
||||
}
|
||||
|
||||
func (r *postgresDeviceRepository) Revoke(ctx context.Context, params RevokeDeviceParams) error {
|
||||
const query = `
|
||||
UPDATE devices
|
||||
SET trust_status = 'revoked',
|
||||
revoked_at = $3,
|
||||
revoked_reason = $4,
|
||||
updated_at = $3
|
||||
WHERE id = $1::uuid AND user_id = $2::uuid
|
||||
`
|
||||
if _, err := r.db.Exec(ctx, query, params.DeviceID, params.UserID, params.RevokedAt, params.Reason); err != nil {
|
||||
return fmt.Errorf("revoke device: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *postgresAuthSessionRepository) Create(ctx context.Context, session AuthSession) error {
|
||||
const query = `
|
||||
INSERT INTO auth_sessions (
|
||||
id,
|
||||
user_id,
|
||||
device_id,
|
||||
refresh_token_hash,
|
||||
refresh_expires_at,
|
||||
last_seen_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, $6, $7, $8)
|
||||
`
|
||||
if _, err := r.db.Exec(ctx, query,
|
||||
session.ID,
|
||||
session.UserID,
|
||||
session.DeviceID,
|
||||
session.RefreshTokenHash,
|
||||
session.RefreshExpiresAt,
|
||||
session.LastSeenAt,
|
||||
session.CreatedAt,
|
||||
session.UpdatedAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("create auth session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *postgresAuthSessionRepository) GetByID(ctx context.Context, authSessionID string) (*AuthSession, error) {
|
||||
return r.getByID(ctx, authSessionID, "")
|
||||
}
|
||||
|
||||
func (r *postgresAuthSessionRepository) GetByIDForUpdate(ctx context.Context, authSessionID string) (*AuthSession, error) {
|
||||
return r.getByID(ctx, authSessionID, " FOR UPDATE")
|
||||
}
|
||||
|
||||
func (r *postgresAuthSessionRepository) getByID(ctx context.Context, authSessionID string, suffix string) (*AuthSession, error) {
|
||||
query := `
|
||||
SELECT id::text, user_id::text, device_id::text, refresh_token_hash, refresh_expires_at,
|
||||
last_seen_at, last_rotated_at, revoked_at, revoked_reason, created_at, updated_at
|
||||
FROM auth_sessions
|
||||
WHERE id = $1::uuid` + suffix
|
||||
session, err := scanAuthSession(r.db.QueryRow(ctx, query, authSessionID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return session, err
|
||||
}
|
||||
|
||||
func (r *postgresAuthSessionRepository) Rotate(ctx context.Context, params RotateAuthSessionParams) error {
|
||||
const query = `
|
||||
UPDATE auth_sessions
|
||||
SET refresh_token_hash = $2,
|
||||
refresh_expires_at = $3,
|
||||
last_seen_at = $4,
|
||||
last_rotated_at = $5,
|
||||
updated_at = $5
|
||||
WHERE id = $1::uuid AND revoked_at IS NULL
|
||||
`
|
||||
if _, err := r.db.Exec(ctx, query,
|
||||
params.AuthSessionID,
|
||||
params.RefreshTokenHash,
|
||||
params.RefreshExpiresAt,
|
||||
params.LastSeenAt,
|
||||
params.LastRotatedAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("rotate auth session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *postgresAuthSessionRepository) Touch(ctx context.Context, authSessionID string, seenAt time.Time) error {
|
||||
const query = `
|
||||
UPDATE auth_sessions
|
||||
SET last_seen_at = $2, updated_at = $2
|
||||
WHERE id = $1::uuid AND revoked_at IS NULL
|
||||
`
|
||||
if _, err := r.db.Exec(ctx, query, authSessionID, seenAt); err != nil {
|
||||
return fmt.Errorf("touch auth session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *postgresAuthSessionRepository) Revoke(ctx context.Context, params RevokeAuthSessionParams) error {
|
||||
const query = `
|
||||
UPDATE auth_sessions
|
||||
SET revoked_at = $3,
|
||||
revoked_reason = $4,
|
||||
updated_at = $3
|
||||
WHERE id = $1::uuid AND user_id = $2::uuid AND revoked_at IS NULL
|
||||
`
|
||||
if _, err := r.db.Exec(ctx, query, params.AuthSessionID, params.UserID, params.RevokedAt, params.Reason); err != nil {
|
||||
return fmt.Errorf("revoke auth session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *postgresAuthSessionRepository) RevokeByDevice(ctx context.Context, userID, deviceID, reason string, revokedAt time.Time) error {
|
||||
const query = `
|
||||
UPDATE auth_sessions
|
||||
SET revoked_at = $3,
|
||||
revoked_reason = $4,
|
||||
updated_at = $3
|
||||
WHERE user_id = $1::uuid AND device_id = $2::uuid AND revoked_at IS NULL
|
||||
`
|
||||
if _, err := r.db.Exec(ctx, query, userID, deviceID, revokedAt, reason); err != nil {
|
||||
return fmt.Errorf("revoke auth sessions by device: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *postgresInstallationRepository) GetStatus(ctx context.Context) (*InstallationAuthorityState, error) {
|
||||
const query = `
|
||||
SELECT install_id, authority_state, product_root_key_fingerprint, bootstrapped_owner_email, bootstrapped_at
|
||||
FROM installation_authority
|
||||
WHERE id = 1
|
||||
`
|
||||
status := &InstallationAuthorityState{}
|
||||
if err := r.db.QueryRow(ctx, query).Scan(
|
||||
&status.InstallID,
|
||||
&status.AuthorityState,
|
||||
&status.ProductRootFingerprint,
|
||||
&status.BootstrappedOwnerEmail,
|
||||
&status.BootstrappedAt,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return &InstallationAuthorityState{
|
||||
Bootstrapped: false,
|
||||
AuthorityState: "unbootstrapped",
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("get installation status: %w", err)
|
||||
}
|
||||
status.Bootstrapped = true
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (r *postgresInstallationRepository) BootstrapOwner(ctx context.Context, params BootstrapOwnerParams) (*User, error) {
|
||||
var existingInstallID string
|
||||
if err := r.db.QueryRow(ctx, `
|
||||
SELECT install_id
|
||||
FROM installation_authority
|
||||
WHERE id = 1
|
||||
FOR UPDATE
|
||||
`).Scan(&existingInstallID); err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("lock installation authority: %w", err)
|
||||
} else if err == nil {
|
||||
return nil, ErrInstallationAlreadyBootstrapped
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(params.Email))
|
||||
now := params.Now.UTC()
|
||||
user, err := scanOptionalUser(r.db.QueryRow(ctx, `
|
||||
INSERT INTO users (email, password_hash, mfa_enabled, platform_role, created_at, updated_at)
|
||||
VALUES ($1, $2, FALSE, $3, $4, $4)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
platform_role = EXCLUDED.platform_role,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id::text, email, password_hash, mfa_enabled, created_at, updated_at
|
||||
`, email, params.PasswordHash, params.Role, now))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert bootstrap owner: %w", err)
|
||||
}
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("upsert bootstrap owner returned no user")
|
||||
}
|
||||
|
||||
payload := json.RawMessage(`{}`)
|
||||
if len(params.ActivationPayload) > 0 {
|
||||
payload = params.ActivationPayload
|
||||
}
|
||||
if _, err := r.db.Exec(ctx, `
|
||||
INSERT INTO installation_authority (
|
||||
id,
|
||||
install_id,
|
||||
authority_state,
|
||||
product_root_key_fingerprint,
|
||||
activation_payload,
|
||||
activation_signature,
|
||||
bootstrapped_owner_email,
|
||||
bootstrapped_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
1,
|
||||
$1,
|
||||
'active',
|
||||
$2,
|
||||
$3::jsonb,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$6,
|
||||
$6
|
||||
)
|
||||
`, params.InstallID, params.ProductRootKeyFingerprint, []byte(payload), params.ActivationSignature, email, now); err != nil {
|
||||
return nil, fmt.Errorf("insert installation authority: %w", err)
|
||||
}
|
||||
|
||||
if _, err := r.db.Exec(ctx, `
|
||||
UPDATE platform_role_grants
|
||||
SET revoked_at = $4
|
||||
WHERE user_id = $1::uuid
|
||||
AND role = $2
|
||||
AND install_id = $3
|
||||
AND revoked_at IS NULL
|
||||
`, user.ID, params.Role, params.InstallID, now); err != nil {
|
||||
return nil, fmt.Errorf("revoke superseded platform role grants: %w", err)
|
||||
}
|
||||
if _, err := r.db.Exec(ctx, `
|
||||
INSERT INTO platform_role_grants (
|
||||
user_id,
|
||||
role,
|
||||
install_id,
|
||||
grant_payload,
|
||||
grant_signature,
|
||||
grant_source,
|
||||
granted_at,
|
||||
expires_at,
|
||||
metadata
|
||||
) VALUES (
|
||||
$1::uuid,
|
||||
$2,
|
||||
$3,
|
||||
$4::jsonb,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
'{"bootstrap_owner":true}'::jsonb
|
||||
)
|
||||
`, user.ID, params.Role, params.InstallID, []byte(payload), params.ActivationSignature, params.GrantSource, now, params.ExpiresAt); err != nil {
|
||||
return nil, fmt.Errorf("insert platform role grant: %w", err)
|
||||
}
|
||||
|
||||
if _, err := r.db.Exec(ctx, `
|
||||
INSERT INTO organization_memberships (
|
||||
organization_id,
|
||||
user_id,
|
||||
role_id,
|
||||
status,
|
||||
invited_by_user_id,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT id, $1::uuid, 'org_owner', 'active', $1::uuid, $2, $2
|
||||
FROM organizations
|
||||
WHERE slug = 'default'
|
||||
ON CONFLICT (organization_id, user_id) DO UPDATE SET
|
||||
role_id = 'org_owner',
|
||||
status = 'active',
|
||||
invited_by_user_id = EXCLUDED.invited_by_user_id,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`, user.ID, now); err != nil {
|
||||
return nil, fmt.Errorf("upsert default organization owner membership: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanOptionalUser(row scanner) (*User, error) {
|
||||
user := &User{}
|
||||
if err := row.Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&user.MFAEnabled,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func scanDevice(row scanner) (*Device, error) {
|
||||
device := &Device{}
|
||||
var trustedAt, lastSeenAt, revokedAt *time.Time
|
||||
var revokedReason *string
|
||||
if err := row.Scan(
|
||||
&device.ID,
|
||||
&device.UserID,
|
||||
&device.Fingerprint,
|
||||
&device.Label,
|
||||
&device.TrustStatus,
|
||||
&trustedAt,
|
||||
&lastSeenAt,
|
||||
&revokedAt,
|
||||
&revokedReason,
|
||||
&device.CreatedAt,
|
||||
&device.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan device: %w", err)
|
||||
}
|
||||
device.TrustedAt = trustedAt
|
||||
device.LastSeenAt = lastSeenAt
|
||||
device.RevokedAt = revokedAt
|
||||
device.RevokedReason = revokedReason
|
||||
return device, nil
|
||||
}
|
||||
|
||||
func scanAuthSession(row scanner) (*AuthSession, error) {
|
||||
session := &AuthSession{}
|
||||
var lastSeenAt, lastRotatedAt, revokedAt *time.Time
|
||||
var revokedReason *string
|
||||
if err := row.Scan(
|
||||
&session.ID,
|
||||
&session.UserID,
|
||||
&session.DeviceID,
|
||||
&session.RefreshTokenHash,
|
||||
&session.RefreshExpiresAt,
|
||||
&lastSeenAt,
|
||||
&lastRotatedAt,
|
||||
&revokedAt,
|
||||
&revokedReason,
|
||||
&session.CreatedAt,
|
||||
&session.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan auth session: %w", err)
|
||||
}
|
||||
session.LastSeenAt = lastSeenAt
|
||||
session.LastRotatedAt = lastRotatedAt
|
||||
session.RevokedAt = revokedAt
|
||||
session.RevokedReason = revokedReason
|
||||
return session, nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
GetByEmail(ctx context.Context, email string) (*User, error)
|
||||
GetByID(ctx context.Context, userID string) (*User, error)
|
||||
}
|
||||
|
||||
type DeviceRepository interface {
|
||||
Upsert(ctx context.Context, params UpsertDeviceParams) (*Device, error)
|
||||
GetByIDForUser(ctx context.Context, userID, deviceID string) (*Device, error)
|
||||
ListTrustedByUser(ctx context.Context, userID string) ([]Device, error)
|
||||
Revoke(ctx context.Context, params RevokeDeviceParams) error
|
||||
}
|
||||
|
||||
type AuthSessionRepository interface {
|
||||
Create(ctx context.Context, session AuthSession) error
|
||||
GetByID(ctx context.Context, authSessionID string) (*AuthSession, error)
|
||||
GetByIDForUpdate(ctx context.Context, authSessionID string) (*AuthSession, error)
|
||||
Rotate(ctx context.Context, params RotateAuthSessionParams) error
|
||||
Touch(ctx context.Context, authSessionID string, seenAt time.Time) error
|
||||
Revoke(ctx context.Context, params RevokeAuthSessionParams) error
|
||||
RevokeByDevice(ctx context.Context, userID, deviceID, reason string, revokedAt time.Time) error
|
||||
}
|
||||
|
||||
type InstallationRepository interface {
|
||||
GetStatus(ctx context.Context) (*InstallationAuthorityState, error)
|
||||
BootstrapOwner(ctx context.Context, params BootstrapOwnerParams) (*User, error)
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
Users() UserRepository
|
||||
Devices() DeviceRepository
|
||||
AuthSessions() AuthSessionRepository
|
||||
Installation() InstallationRepository
|
||||
}
|
||||
|
||||
type Transactor interface {
|
||||
WithinTransaction(ctx context.Context, fn func(store Store) error) error
|
||||
}
|
||||
|
||||
type UpsertDeviceParams struct {
|
||||
UserID string
|
||||
Fingerprint string
|
||||
Label string
|
||||
TrustRequested bool
|
||||
SeenAt time.Time
|
||||
}
|
||||
|
||||
type RotateAuthSessionParams struct {
|
||||
AuthSessionID string
|
||||
RefreshTokenHash string
|
||||
RefreshExpiresAt time.Time
|
||||
LastSeenAt time.Time
|
||||
LastRotatedAt time.Time
|
||||
}
|
||||
|
||||
type RevokeAuthSessionParams struct {
|
||||
AuthSessionID string
|
||||
UserID string
|
||||
Reason string
|
||||
RevokedAt time.Time
|
||||
}
|
||||
|
||||
type RevokeDeviceParams struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
Reason string
|
||||
RevokedAt time.Time
|
||||
}
|
||||
|
||||
type InstallationAuthorityState struct {
|
||||
Bootstrapped bool
|
||||
AuthorityState string
|
||||
InstallID string
|
||||
ProductRootFingerprint string
|
||||
BootstrappedOwnerEmail string
|
||||
BootstrappedAt *time.Time
|
||||
}
|
||||
|
||||
type BootstrapOwnerParams struct {
|
||||
Email string
|
||||
PasswordHash string
|
||||
Role string
|
||||
InstallID string
|
||||
ProductRootKeyFingerprint string
|
||||
ActivationPayload json.RawMessage
|
||||
ActivationSignature string
|
||||
GrantSource string
|
||||
ExpiresAt *time.Time
|
||||
Now time.Time
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type TokenManager struct {
|
||||
issuer string
|
||||
accessSecret []byte
|
||||
refreshHashSecret []byte
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
}
|
||||
|
||||
type AccessClaims struct {
|
||||
AuthSessionID string `json:"sid"`
|
||||
DeviceID string `json:"did"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewTokenManager(cfg TokenConfig) *TokenManager {
|
||||
return &TokenManager{
|
||||
issuer: cfg.Issuer,
|
||||
accessSecret: []byte(cfg.AccessTokenSecret),
|
||||
refreshHashSecret: []byte(cfg.RefreshHashSecret),
|
||||
accessTTL: cfg.AccessTokenTTL,
|
||||
refreshTTL: cfg.RefreshTokenTTL,
|
||||
}
|
||||
}
|
||||
|
||||
type TokenConfig struct {
|
||||
Issuer string
|
||||
AccessTokenSecret string
|
||||
RefreshHashSecret string
|
||||
AccessTokenTTL time.Duration
|
||||
RefreshTokenTTL time.Duration
|
||||
}
|
||||
|
||||
func (m *TokenManager) IssueAccessToken(userID, authSessionID, deviceID string, now time.Time) (string, time.Time, error) {
|
||||
expiresAt := now.Add(m.accessTTL)
|
||||
claims := AccessClaims{
|
||||
AuthSessionID: authSessionID,
|
||||
DeviceID: deviceID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: m.issuer,
|
||||
Subject: userID,
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(m.accessSecret)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("sign access token: %w", err)
|
||||
}
|
||||
|
||||
return signed, expiresAt, nil
|
||||
}
|
||||
|
||||
func (m *TokenManager) IssueRefreshToken(authSessionID string, now time.Time) (raw string, hash string, expiresAt time.Time, err error) {
|
||||
secret := make([]byte, 32)
|
||||
if _, err = rand.Read(secret); err != nil {
|
||||
return "", "", time.Time{}, fmt.Errorf("read random refresh secret: %w", err)
|
||||
}
|
||||
|
||||
encodedSecret := base64.RawURLEncoding.EncodeToString(secret)
|
||||
raw = authSessionID + "." + encodedSecret
|
||||
hash = m.HashRefreshToken(raw)
|
||||
expiresAt = now.Add(m.refreshTTL)
|
||||
return raw, hash, expiresAt, nil
|
||||
}
|
||||
|
||||
func (m *TokenManager) HashRefreshToken(token string) string {
|
||||
mac := hmac.New(sha256.New, m.refreshHashSecret)
|
||||
_, _ = mac.Write([]byte(token))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (m *TokenManager) ParseRefreshToken(token string) (string, error) {
|
||||
sessionID, _, ok := strings.Cut(token, ".")
|
||||
if !ok || sessionID == "" {
|
||||
return "", ErrInvalidRefreshToken
|
||||
}
|
||||
return sessionID, nil
|
||||
}
|
||||
Reference in New Issue
Block a user