Initial project snapshot

This commit is contained in:
2026-04-28 22:29:50 +03:00
commit 8ba0561f4f
365 changed files with 91832 additions and 0 deletions
+19
View File
@@ -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")
)
+114
View File
@@ -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"`
}
+173
View File
@@ -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
}
+440
View File
@@ -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()
}
+95
View File
@@ -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
}