initial commit
This commit is contained in:
commit
54576d9d33
61 changed files with 5569 additions and 0 deletions
102
internal/config/config.go
Normal file
102
internal/config/config.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
BaseURL string
|
||||
AuthDomain string
|
||||
DatabaseURL string
|
||||
SessionSecret string
|
||||
DevLogin bool
|
||||
DevUser string
|
||||
LocalLogin bool
|
||||
LocalAdminEmail string
|
||||
LocalAdminPassword string
|
||||
OIDCIssuer string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
OIDCRedirectURL string
|
||||
AllowedEmailDomains []string
|
||||
AllowPrivateContractURL bool
|
||||
BootstrapTenant string
|
||||
BootstrapApp string
|
||||
HTTPTimeout time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Addr: env("GO_LOOSE_ADDR", ":8080"),
|
||||
BaseURL: strings.TrimRight(env("GO_LOOSE_BASE_URL", "http://localhost:8080"), "/"),
|
||||
AuthDomain: strings.TrimPrefix(strings.ToLower(env("GO_LOOSE_AUTH_DOMAIN", "auth.local")), "."),
|
||||
DatabaseURL: env("GO_LOOSE_DATABASE_URL", "postgres://goloose:goloose@localhost:5432/goloose?sslmode=disable"),
|
||||
SessionSecret: os.Getenv("GO_LOOSE_SESSION_SECRET"),
|
||||
DevLogin: envBool("GO_LOOSE_DEV_LOGIN", false),
|
||||
DevUser: env("GO_LOOSE_DEV_USER", "admin@local.test"),
|
||||
LocalLogin: envBool("GO_LOOSE_LOCAL_LOGIN", false),
|
||||
LocalAdminEmail: strings.ToLower(env("GO_LOOSE_LOCAL_ADMIN_EMAIL", "admin@goloose.local")),
|
||||
LocalAdminPassword: os.Getenv("GO_LOOSE_LOCAL_ADMIN_PASSWORD"),
|
||||
OIDCIssuer: strings.TrimRight(os.Getenv("GO_LOOSE_OIDC_ISSUER"), "/"),
|
||||
OIDCClientID: os.Getenv("GO_LOOSE_OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("GO_LOOSE_OIDC_CLIENT_SECRET"),
|
||||
OIDCRedirectURL: env("GO_LOOSE_OIDC_REDIRECT_URL", "http://localhost:8080/auth/callback"),
|
||||
AllowedEmailDomains: csv(os.Getenv("GO_LOOSE_ALLOWED_EMAIL_DOMAINS")),
|
||||
AllowPrivateContractURL: envBool("GO_LOOSE_ALLOW_PRIVATE_CONTRACT_URLS", false),
|
||||
BootstrapTenant: env("GO_LOOSE_BOOTSTRAP_TENANT", "default"),
|
||||
BootstrapApp: env("GO_LOOSE_BOOTSTRAP_APP", "default"),
|
||||
HTTPTimeout: 10 * time.Second,
|
||||
}
|
||||
if len(cfg.SessionSecret) < 32 {
|
||||
return Config{}, errors.New("GO_LOOSE_SESSION_SECRET must contain at least 32 characters")
|
||||
}
|
||||
if cfg.AuthDomain == "" || strings.ContainsAny(cfg.AuthDomain, "/:") {
|
||||
return Config{}, errors.New("GO_LOOSE_AUTH_DOMAIN must be a hostname such as auth.local")
|
||||
}
|
||||
oidcConfigured := cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" && cfg.OIDCClientSecret != ""
|
||||
if cfg.LocalAdminPassword != "" && len(cfg.LocalAdminPassword) < 12 {
|
||||
return Config{}, errors.New("GO_LOOSE_LOCAL_ADMIN_PASSWORD must contain at least 12 characters")
|
||||
}
|
||||
if oidcConfigured {
|
||||
baseURL, err := url.Parse(cfg.BaseURL)
|
||||
if err != nil || (baseURL.Hostname() != cfg.AuthDomain && !strings.HasSuffix(baseURL.Hostname(), "."+cfg.AuthDomain)) {
|
||||
return Config{}, errors.New("GO_LOOSE_BASE_URL must be inside GO_LOOSE_AUTH_DOMAIN for shared OIDC login")
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func env(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(name string, fallback bool) bool {
|
||||
value := os.Getenv(name)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func csv(value string) []string {
|
||||
var result []string
|
||||
for _, item := range strings.Split(value, ",") {
|
||||
if item = strings.TrimSpace(strings.ToLower(item)); item != "" {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
45
internal/config/config_test.go
Normal file
45
internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadRequiresSessionSecret(t *testing.T) {
|
||||
t.Setenv("GO_LOOSE_SESSION_SECRET", "short")
|
||||
t.Setenv("GO_LOOSE_DEV_LOGIN", "true")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected an error for a short session secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDevelopmentConfiguration(t *testing.T) {
|
||||
t.Setenv("GO_LOOSE_SESSION_SECRET", "01234567890123456789012345678901")
|
||||
t.Setenv("GO_LOOSE_DEV_LOGIN", "true")
|
||||
t.Setenv("GO_LOOSE_ALLOWED_EMAIL_DOMAINS", "Example.com, internal.test")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if len(cfg.AllowedEmailDomains) != 2 || cfg.AllowedEmailDomains[0] != "example.com" {
|
||||
t.Fatalf("unexpected domains: %#v", cfg.AllowedEmailDomains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllowsFirstRunWithoutConfiguredLogin(t *testing.T) {
|
||||
t.Setenv("GO_LOOSE_SESSION_SECRET", "01234567890123456789012345678901")
|
||||
t.Setenv("GO_LOOSE_DEV_LOGIN", "false")
|
||||
t.Setenv("GO_LOOSE_LOCAL_LOGIN", "false")
|
||||
t.Setenv("GO_LOOSE_OIDC_ISSUER", "")
|
||||
t.Setenv("GO_LOOSE_OIDC_CLIENT_ID", "")
|
||||
t.Setenv("GO_LOOSE_OIDC_CLIENT_SECRET", "")
|
||||
if _, err := Load(); err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllowsDatabaseLoginWithoutBootstrapAccount(t *testing.T) {
|
||||
t.Setenv("GO_LOOSE_SESSION_SECRET", "01234567890123456789012345678901")
|
||||
t.Setenv("GO_LOOSE_LOCAL_LOGIN", "true")
|
||||
t.Setenv("GO_LOOSE_LOCAL_ADMIN_PASSWORD", "")
|
||||
if _, err := Load(); err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
}
|
||||
140
internal/contract/contract.go
Normal file
140
internal/contract/contract.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Endpoint struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
OperationID string `json:"operation_id"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
Version string
|
||||
JSON []byte
|
||||
Endpoints []Endpoint
|
||||
}
|
||||
|
||||
func Parse(content []byte) (Document, error) {
|
||||
var value map[string]any
|
||||
if json.Unmarshal(content, &value) != nil {
|
||||
var yamlValue any
|
||||
if err := yaml.Unmarshal(content, &yamlValue); err != nil {
|
||||
return Document{}, fmt.Errorf("parse OpenAPI document: %w", err)
|
||||
}
|
||||
normalized, err := normalizeYAML(yamlValue)
|
||||
if err != nil {
|
||||
return Document{}, err
|
||||
}
|
||||
value, _ = normalized.(map[string]any)
|
||||
}
|
||||
version, _ := value["openapi"].(string)
|
||||
if version == "" {
|
||||
if swagger, _ := value["swagger"].(string); swagger != "" {
|
||||
version = swagger
|
||||
}
|
||||
}
|
||||
if version == "" {
|
||||
return Document{}, errors.New("document is not an OpenAPI or Swagger contract")
|
||||
}
|
||||
var endpoints []Endpoint
|
||||
paths, _ := value["paths"].(map[string]any)
|
||||
for path, rawOperations := range paths {
|
||||
operations, _ := rawOperations.(map[string]any)
|
||||
for method, rawOperation := range operations {
|
||||
method = strings.ToUpper(method)
|
||||
if !isHTTPMethod(method) {
|
||||
continue
|
||||
}
|
||||
operation, _ := rawOperation.(map[string]any)
|
||||
operationID, _ := operation["operationId"].(string)
|
||||
endpoints = append(endpoints, Endpoint{Method: method, Path: path, OperationID: operationID})
|
||||
}
|
||||
}
|
||||
sort.Slice(endpoints, func(i, j int) bool {
|
||||
if endpoints[i].Path == endpoints[j].Path {
|
||||
return endpoints[i].Method < endpoints[j].Method
|
||||
}
|
||||
return endpoints[i].Path < endpoints[j].Path
|
||||
})
|
||||
canonical, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return Document{}, fmt.Errorf("encode OpenAPI document: %w", err)
|
||||
}
|
||||
return Document{Version: version, JSON: canonical, Endpoints: endpoints}, nil
|
||||
}
|
||||
|
||||
func ValidateSourceURL(raw string, allowPrivate bool) (*url.URL, error) {
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" {
|
||||
return nil, errors.New("source URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
if allowPrivate {
|
||||
return parsed, nil
|
||||
}
|
||||
addresses, err := net.LookupIP(parsed.Hostname())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve source host: %w", err)
|
||||
}
|
||||
for _, address := range addresses {
|
||||
if address.IsLoopback() || address.IsPrivate() || address.IsLinkLocalUnicast() || address.IsUnspecified() {
|
||||
return nil, errors.New("private source URLs are disabled")
|
||||
}
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func normalizeYAML(value any) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
result := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
normalized, err := normalizeYAML(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[key] = normalized
|
||||
}
|
||||
return result, nil
|
||||
case map[any]any:
|
||||
result := make(map[string]any, len(typed))
|
||||
for key, item := range typed {
|
||||
stringKey, ok := key.(string)
|
||||
if !ok {
|
||||
return nil, errors.New("OpenAPI object contains a non-string key")
|
||||
}
|
||||
normalized, err := normalizeYAML(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[stringKey] = normalized
|
||||
}
|
||||
return result, nil
|
||||
case []any:
|
||||
result := make([]any, len(typed))
|
||||
for i, item := range typed {
|
||||
normalized, err := normalizeYAML(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i] = normalized
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return typed, nil
|
||||
}
|
||||
}
|
||||
|
||||
func isHTTPMethod(method string) bool {
|
||||
return bytes.Contains([]byte(" GET PUT POST DELETE PATCH HEAD OPTIONS TRACE "), []byte(" "+method+" "))
|
||||
}
|
||||
26
internal/contract/contract_test.go
Normal file
26
internal/contract/contract_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package contract
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseYAML(t *testing.T) {
|
||||
document, err := Parse([]byte(`openapi: 3.1.0
|
||||
paths:
|
||||
/guesses:
|
||||
get:
|
||||
operationId: listGuesses
|
||||
post:
|
||||
operationId: createGuess
|
||||
`))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse() error = %v", err)
|
||||
}
|
||||
if document.Version != "3.1.0" || len(document.Endpoints) != 2 {
|
||||
t.Fatalf("unexpected document: %#v", document)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateSourceURLRejectsLoopback(t *testing.T) {
|
||||
if _, err := ValidateSourceURL("http://127.0.0.1/openapi.json", false); err == nil {
|
||||
t.Fatal("expected loopback URL to be rejected")
|
||||
}
|
||||
}
|
||||
100
internal/database/database.go
Normal file
100
internal/database/database.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
func Open(ctx context.Context, databaseURL string) (*sql.DB, error) {
|
||||
db, err := sql.Open("pgx", databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(20)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func Migrate(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create migration table: %w", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(migrationFiles, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
var applied bool
|
||||
err := db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, entry.Name()).Scan(&applied)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
if applied {
|
||||
continue
|
||||
}
|
||||
content, err := migrationFiles.ReadFile("migrations/" + entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
up, err := upSection(string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, up); err == nil {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO schema_migrations(version) VALUES ($1)`, entry.Name())
|
||||
}
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upSection(content string) (string, error) {
|
||||
const upMarker = "-- +goose Up"
|
||||
const downMarker = "-- +goose Down"
|
||||
start := strings.Index(content, upMarker)
|
||||
if start < 0 {
|
||||
return "", errors.New("missing up marker")
|
||||
}
|
||||
content = content[start+len(upMarker):]
|
||||
if end := strings.Index(content, downMarker); end >= 0 {
|
||||
content = content[:end]
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return "", errors.New("empty up migration")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
13
internal/database/database_test.go
Normal file
13
internal/database/database_test.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package database
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestUpSection(t *testing.T) {
|
||||
got, err := upSection("-- +goose Up\nCREATE TABLE example(id int);\n-- +goose Down\nDROP TABLE example;")
|
||||
if err != nil {
|
||||
t.Fatalf("upSection() error = %v", err)
|
||||
}
|
||||
if got != "\nCREATE TABLE example(id int);\n" {
|
||||
t.Fatalf("unexpected section: %q", got)
|
||||
}
|
||||
}
|
||||
101
internal/database/migrations/0001_initial.sql
Normal file
101
internal/database/migrations/0001_initial.sql
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
-- +goose Up
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE tenants (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,62}$'),
|
||||
name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
subject text NOT NULL UNIQUE,
|
||||
email text NOT NULL,
|
||||
display_name text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_login_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE memberships (
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role text NOT NULL CHECK (role IN ('owner', 'admin', 'viewer')),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE applications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{1,62}$'),
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
allowed_hosts text[] NOT NULL DEFAULT '{}',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, slug)
|
||||
);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
prefix text NOT NULL,
|
||||
secret_hash bytea NOT NULL UNIQUE,
|
||||
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
|
||||
expires_at timestamptz,
|
||||
last_used_at timestamptz,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX api_keys_lookup_idx ON api_keys(secret_hash) WHERE status = 'active';
|
||||
CREATE INDEX api_keys_application_idx ON api_keys(application_id);
|
||||
|
||||
CREATE TABLE openapi_contracts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
version text NOT NULL,
|
||||
source_url text,
|
||||
document jsonb NOT NULL,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE contract_endpoints (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
contract_id uuid NOT NULL REFERENCES openapi_contracts(id) ON DELETE CASCADE,
|
||||
method text NOT NULL,
|
||||
path text NOT NULL,
|
||||
operation_id text NOT NULL DEFAULT '',
|
||||
UNIQUE (contract_id, method, path)
|
||||
);
|
||||
|
||||
CREATE TABLE authorization_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
tenant_id uuid REFERENCES tenants(id) ON DELETE SET NULL,
|
||||
application_id uuid REFERENCES applications(id) ON DELETE SET NULL,
|
||||
api_key_id uuid REFERENCES api_keys(id) ON DELETE SET NULL,
|
||||
method text NOT NULL,
|
||||
path text NOT NULL,
|
||||
host text NOT NULL,
|
||||
allowed boolean NOT NULL,
|
||||
reason text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX authorization_events_occurred_idx ON authorization_events(occurred_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS authorization_events;
|
||||
DROP TABLE IF EXISTS contract_endpoints;
|
||||
DROP TABLE IF EXISTS openapi_contracts;
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
DROP TABLE IF EXISTS applications;
|
||||
DROP TABLE IF EXISTS memberships;
|
||||
DROP TABLE IF EXISTS users;
|
||||
DROP TABLE IF EXISTS tenants;
|
||||
54
internal/database/migrations/0002_client_login.sql
Normal file
54
internal/database/migrations/0002_client_login.sql
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
-- +goose Up
|
||||
ALTER TABLE applications
|
||||
ADD COLUMN client_id text NOT NULL UNIQUE DEFAULT ('glc_' || encode(gen_random_bytes(18), 'hex')),
|
||||
ADD COLUMN client_secret_hash bytea,
|
||||
ADD COLUMN redirect_uris text[] NOT NULL DEFAULT '{}';
|
||||
|
||||
CREATE UNIQUE INDEX users_email_unique_idx ON users (lower(email));
|
||||
|
||||
CREATE TABLE user_application_access (
|
||||
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
granted_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (application_id, user_id)
|
||||
);
|
||||
|
||||
INSERT INTO user_application_access(application_id, user_id, granted_by)
|
||||
SELECT a.id, m.user_id, m.user_id
|
||||
FROM applications a
|
||||
JOIN memberships m ON m.tenant_id = a.tenant_id
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
CREATE TABLE client_authorization_codes (
|
||||
code_hash bytea PRIMARY KEY,
|
||||
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
redirect_uri text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE client_user_sessions (
|
||||
token_hash bytea PRIMARY KEY,
|
||||
application_id uuid NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
last_used_at timestamptz,
|
||||
revoked_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX client_user_sessions_user_idx ON client_user_sessions(user_id);
|
||||
CREATE INDEX client_authorization_codes_expiry_idx ON client_authorization_codes(expires_at);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS client_user_sessions;
|
||||
DROP TABLE IF EXISTS client_authorization_codes;
|
||||
DROP TABLE IF EXISTS user_application_access;
|
||||
DROP INDEX IF EXISTS users_email_unique_idx;
|
||||
ALTER TABLE applications
|
||||
DROP COLUMN IF EXISTS redirect_uris,
|
||||
DROP COLUMN IF EXISTS client_secret_hash,
|
||||
DROP COLUMN IF EXISTS client_id;
|
||||
23
internal/database/migrations/0003_password_auth.sql
Normal file
23
internal/database/migrations/0003_password_auth.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
-- +goose Up
|
||||
ALTER TABLE users
|
||||
ADD COLUMN password_hash text,
|
||||
ADD COLUMN password_changed_at timestamptz;
|
||||
|
||||
CREATE TABLE user_login_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
email text NOT NULL,
|
||||
remote_address text NOT NULL,
|
||||
successful boolean NOT NULL,
|
||||
reason text NOT NULL,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX user_login_events_occurred_idx ON user_login_events(occurred_at DESC);
|
||||
CREATE INDEX user_login_events_email_idx ON user_login_events(lower(email), occurred_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS user_login_events;
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS password_changed_at,
|
||||
DROP COLUMN IF EXISTS password_hash;
|
||||
18
internal/database/migrations/0004_installer.sql
Normal file
18
internal/database/migrations/0004_installer.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
-- +goose Up
|
||||
CREATE TABLE installation_settings (
|
||||
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
|
||||
installed_at timestamptz NOT NULL DEFAULT now(),
|
||||
oidc_issuer text NOT NULL,
|
||||
oidc_client_id text NOT NULL,
|
||||
oidc_client_secret_ciphertext bytea NOT NULL,
|
||||
seeded_demo boolean NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE TABLE system_administrators (
|
||||
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS system_administrators;
|
||||
DROP TABLE IF EXISTS installation_settings;
|
||||
38
internal/key/key.go
Normal file
38
internal/key/key.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package key
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Prefix = "gl_"
|
||||
|
||||
func Generate() (plain, displayPrefix string, hash []byte, err error) {
|
||||
return GenerateToken(Prefix)
|
||||
}
|
||||
|
||||
func GenerateToken(prefix string) (plain, displayPrefix string, hash []byte, err error) {
|
||||
random := make([]byte, 32)
|
||||
if _, err = rand.Read(random); err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
plain = prefix + base64.RawURLEncoding.EncodeToString(random)
|
||||
displayPrefix = plain[:min(len(plain), 11)]
|
||||
hash = Hash(plain)
|
||||
return plain, displayPrefix, hash, nil
|
||||
}
|
||||
|
||||
func Hash(plain string) []byte {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
func ValidateFormat(plain string) error {
|
||||
if !strings.HasPrefix(plain, Prefix) || len(plain) < 40 {
|
||||
return errors.New("invalid API key format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
22
internal/key/key_test.go
Normal file
22
internal/key/key_test.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package key
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerate(t *testing.T) {
|
||||
plain, prefix, hash, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
if err := ValidateFormat(plain); err != nil {
|
||||
t.Fatalf("generated key is invalid: %v", err)
|
||||
}
|
||||
if prefix != plain[:11] {
|
||||
t.Fatalf("prefix = %q", prefix)
|
||||
}
|
||||
if !bytes.Equal(hash, Hash(plain)) {
|
||||
t.Fatal("hash mismatch")
|
||||
}
|
||||
}
|
||||
90
internal/password/password.go
Normal file
90
internal/password/password.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package password
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
memory = 64 * 1024
|
||||
iterations = 3
|
||||
parallelism = 2
|
||||
saltLength = 16
|
||||
keyLength = 32
|
||||
)
|
||||
|
||||
func Hash(value string) (string, error) {
|
||||
if len(value) < 12 {
|
||||
return "", errors.New("password must contain at least 12 characters")
|
||||
}
|
||||
return hash(value)
|
||||
}
|
||||
|
||||
// HashDemo hashes intentionally weak, local-only seed credentials.
|
||||
func HashDemo(value string) (string, error) {
|
||||
if value == "" {
|
||||
return "", errors.New("demo password is empty")
|
||||
}
|
||||
return hash(value)
|
||||
}
|
||||
|
||||
func hash(value string) (string, error) {
|
||||
salt := make([]byte, saltLength)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("generate password salt: %w", err)
|
||||
}
|
||||
hash := argon2.IDKey([]byte(value), salt, iterations, memory, parallelism, keyLength)
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
memory, iterations, parallelism,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
), nil
|
||||
}
|
||||
|
||||
func Verify(encoded, value string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" || parts[2] != "v=19" {
|
||||
return false
|
||||
}
|
||||
var parsedMemory uint64
|
||||
var parsedIterations uint64
|
||||
var parsedParallelism uint64
|
||||
for _, setting := range strings.Split(parts[3], ",") {
|
||||
keyValue := strings.SplitN(setting, "=", 2)
|
||||
if len(keyValue) != 2 {
|
||||
return false
|
||||
}
|
||||
number, err := strconv.ParseUint(keyValue[1], 10, 32)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch keyValue[0] {
|
||||
case "m":
|
||||
parsedMemory = number
|
||||
case "t":
|
||||
parsedIterations = number
|
||||
case "p":
|
||||
parsedParallelism = number
|
||||
}
|
||||
}
|
||||
if parsedMemory == 0 || parsedMemory > 256*1024 || parsedIterations == 0 || parsedIterations > 10 || parsedParallelism == 0 || parsedParallelism > 8 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil || len(salt) < 8 {
|
||||
return false
|
||||
}
|
||||
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil || len(expected) < 16 {
|
||||
return false
|
||||
}
|
||||
actual := argon2.IDKey([]byte(value), salt, uint32(parsedIterations), uint32(parsedMemory), uint8(parsedParallelism), uint32(len(expected)))
|
||||
return subtle.ConstantTimeCompare(actual, expected) == 1
|
||||
}
|
||||
22
internal/password/password_test.go
Normal file
22
internal/password/password_test.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package password
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHashAndVerify(t *testing.T) {
|
||||
encoded, err := Hash("correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
if !Verify(encoded, "correct horse battery staple") {
|
||||
t.Fatal("expected password to verify")
|
||||
}
|
||||
if Verify(encoded, "incorrect password") {
|
||||
t.Fatal("expected incorrect password to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashRejectsShortPassword(t *testing.T) {
|
||||
if _, err := Hash("too-short"); err == nil {
|
||||
t.Fatal("expected short password to fail")
|
||||
}
|
||||
}
|
||||
51
internal/secretbox/secretbox.go
Normal file
51
internal/secretbox/secretbox.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package secretbox
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func Encrypt(master, plaintext string) ([]byte, error) {
|
||||
block, err := aes.NewCipher(deriveKey(master))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create secret cipher: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create secret AEAD: %w", err)
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, fmt.Errorf("generate secret nonce: %w", err)
|
||||
}
|
||||
return aead.Seal(nonce, nonce, []byte(plaintext), []byte("go-loose-oidc-v1")), nil
|
||||
}
|
||||
|
||||
func Decrypt(master string, ciphertext []byte) (string, error) {
|
||||
block, err := aes.NewCipher(deriveKey(master))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create secret cipher: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create secret AEAD: %w", err)
|
||||
}
|
||||
if len(ciphertext) < aead.NonceSize() {
|
||||
return "", errors.New("encrypted secret is truncated")
|
||||
}
|
||||
nonce, sealed := ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():]
|
||||
plaintext, err := aead.Open(nil, nonce, sealed, []byte("go-loose-oidc-v1"))
|
||||
if err != nil {
|
||||
return "", errors.New("encrypted secret cannot be opened")
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func deriveKey(master string) []byte {
|
||||
sum := sha256.Sum256([]byte("go-loose/oidc/" + master))
|
||||
return sum[:]
|
||||
}
|
||||
20
internal/secretbox/secretbox_test.go
Normal file
20
internal/secretbox/secretbox_test.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package secretbox
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
ciphertext, err := Encrypt("01234567890123456789012345678901", "client-secret")
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt() error = %v", err)
|
||||
}
|
||||
plaintext, err := Decrypt("01234567890123456789012345678901", ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt() error = %v", err)
|
||||
}
|
||||
if plaintext != "client-secret" {
|
||||
t.Fatalf("plaintext = %q", plaintext)
|
||||
}
|
||||
if _, err := Decrypt("different-master-secret-value-0000", ciphertext); err == nil {
|
||||
t.Fatal("expected wrong key to fail")
|
||||
}
|
||||
}
|
||||
143
internal/server/clientauth.go
Normal file
143
internal/server/clientauth.go
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-loose/internal/key"
|
||||
"github.com/danyel/go-loose/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) clientAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
clientID, redirectURI, state := query.Get("client_id"), query.Get("redirect_uri"), query.Get("state")
|
||||
if query.Get("response_type") != "code" || clientID == "" || redirectURI == "" || state == "" {
|
||||
writeError(w, http.StatusBadRequest, "response_type=code, client_id, redirect_uri, and state are required")
|
||||
return
|
||||
}
|
||||
app, err := s.store.ValidateClientRedirect(r.Context(), clientID, redirectURI)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusBadRequest, "unknown client or redirect URI")
|
||||
return
|
||||
}
|
||||
s.internalError(w, "validate client redirect", err)
|
||||
return
|
||||
}
|
||||
if !strings.EqualFold(r.Host, s.tenantAuthHost(app.TenantSlug)) {
|
||||
http.Redirect(w, r, s.tenantAuthURL(app.TenantSlug, r.URL.RequestURI()), http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
claims, err := s.sessions.Get(r)
|
||||
if err != nil {
|
||||
returnURL := s.tenantAuthURL(app.TenantSlug, r.URL.RequestURI())
|
||||
loginQuery := url.Values{"client_id": {clientID}, "return": {returnURL}}
|
||||
http.Redirect(w, r, "/client/login?"+loginQuery.Encode(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
app, err = s.store.ClientForAuthorization(r.Context(), clientID, redirectURI, claims.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusForbidden, "your account does not have access to this application")
|
||||
return
|
||||
}
|
||||
s.internalError(w, "authorize client user", err)
|
||||
return
|
||||
}
|
||||
code, _, codeHash, err := key.GenerateToken("glc_code_")
|
||||
if err != nil {
|
||||
s.internalError(w, "generate authorization code", err)
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateAuthorizationCode(r.Context(), app.ID, claims.UserID, redirectURI, codeHash); err != nil {
|
||||
s.internalError(w, "store authorization code", err)
|
||||
return
|
||||
}
|
||||
target, _ := url.Parse(redirectURI)
|
||||
values := target.Query()
|
||||
values.Set("code", code)
|
||||
values.Set("state", state)
|
||||
target.RawQuery = values.Encode()
|
||||
http.Redirect(w, r, target.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) clientToken(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid token request")
|
||||
return
|
||||
}
|
||||
clientID, clientSecret, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Go Loose token endpoint"`)
|
||||
writeError(w, http.StatusUnauthorized, "HTTP Basic client authentication required")
|
||||
return
|
||||
}
|
||||
code, redirectURI := r.Form.Get("code"), r.Form.Get("redirect_uri")
|
||||
if r.Form.Get("grant_type") != "authorization_code" || clientID == "" || clientSecret == "" || code == "" || redirectURI == "" {
|
||||
writeError(w, http.StatusBadRequest, "authorization_code grant parameters are required")
|
||||
return
|
||||
}
|
||||
token, _, tokenHash, err := key.GenerateToken("glc_session_")
|
||||
if err != nil {
|
||||
s.internalError(w, "generate client session", err)
|
||||
return
|
||||
}
|
||||
user, expiresAt, err := s.store.ExchangeAuthorizationCode(
|
||||
r.Context(), clientID, redirectURI, key.Hash(clientSecret), key.Hash(code), tokenHash,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid or expired authorization code")
|
||||
return
|
||||
}
|
||||
s.internalError(w, "exchange authorization code", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"access_token": token, "token_type": "Bearer",
|
||||
"expires_in": int(time.Until(expiresAt).Seconds()), "user": user,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) clientUserInfo(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Bearer token required")
|
||||
return
|
||||
}
|
||||
user, err := s.store.ClientUserByToken(r.Context(), key.Hash(token))
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusUnauthorized, "session is invalid or expired")
|
||||
return
|
||||
}
|
||||
s.internalError(w, "load client user", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Bearer token required")
|
||||
return
|
||||
}
|
||||
if err := s.store.RevokeClientSession(r.Context(), key.Hash(token)); err != nil && !errors.Is(err, store.ErrNotFound) {
|
||||
s.internalError(w, "revoke client session", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
value := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(value, "Bearer ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(value, "Bearer "))
|
||||
}
|
||||
133
internal/server/installer.go
Normal file
133
internal/server/installer.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/danyel/go-loose/internal/password"
|
||||
"github.com/danyel/go-loose/internal/secretbox"
|
||||
"github.com/danyel/go-loose/internal/store"
|
||||
"github.com/danyel/go-loose/web"
|
||||
)
|
||||
|
||||
const googleDemoClientID = "554628171917-forvpfs44pqajv77dfhl503prfqnhor3.apps.googleusercontent.com"
|
||||
|
||||
func (s *Server) installPage(w http.ResponseWriter, r *http.Request) {
|
||||
if s.installed.Load() {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
content, err := web.Files.ReadFile("install.html")
|
||||
if err != nil {
|
||||
http.Error(w, "installer unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
csrfToken, err := s.installCSRFToken(w)
|
||||
if err != nil {
|
||||
http.Error(w, "could not prepare installer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
page := strings.NewReplacer(
|
||||
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
|
||||
"{{GOOGLE_CLIENT_ID}}", googleDemoClientID,
|
||||
"{{CALLBACK_URL}}", html.EscapeString(s.cfg.OIDCRedirectURL),
|
||||
).Replace(string(content))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(page))
|
||||
}
|
||||
|
||||
func (s *Server) install(w http.ResponseWriter, r *http.Request) {
|
||||
if s.installed.Load() {
|
||||
writeError(w, http.StatusConflict, "Go Loose is already installed")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid installer request")
|
||||
return
|
||||
}
|
||||
csrfCookie, cookieErr := r.Cookie("go_loose_install_csrf")
|
||||
csrfValue := r.Form.Get("csrf_token")
|
||||
payload, valid := s.sessions.VerifySigned(csrfValue)
|
||||
if cookieErr != nil || subtle.ConstantTimeCompare([]byte(csrfCookie.Value), []byte(csrfValue)) != 1 ||
|
||||
!valid || !strings.HasPrefix(payload, "install|") {
|
||||
writeError(w, http.StatusForbidden, "invalid installer form; reload the installer")
|
||||
return
|
||||
}
|
||||
clientID := strings.TrimSpace(r.Form.Get("oidc_client_id"))
|
||||
clientSecret := strings.TrimSpace(r.Form.Get("oidc_client_secret"))
|
||||
if clientID == "" || clientSecret == "" {
|
||||
writeError(w, http.StatusBadRequest, "Google client ID and client secret are required")
|
||||
return
|
||||
}
|
||||
if err := s.configureOIDC(r.Context(), "https://accounts.google.com", clientID, clientSecret); err != nil {
|
||||
writeError(w, http.StatusBadGateway, "Google OIDC discovery failed")
|
||||
return
|
||||
}
|
||||
ciphertext, err := secretbox.Encrypt(s.cfg.SessionSecret, clientSecret)
|
||||
if err != nil {
|
||||
s.internalError(w, "encrypt OIDC secret", err)
|
||||
return
|
||||
}
|
||||
seedDemo := r.Form.Get("seed_demo") == "on"
|
||||
var demoUsers []store.DemoUser
|
||||
if seedDemo {
|
||||
demoUsers, err = buildDemoUsers()
|
||||
if err != nil {
|
||||
s.internalError(w, "hash demo credentials", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
err = s.store.Install(r.Context(), store.Installation{
|
||||
OIDCIssuer: "https://accounts.google.com", OIDCClientID: clientID,
|
||||
OIDCSecretCiphertext: ciphertext, SeededDemo: seedDemo,
|
||||
}, demoUsers)
|
||||
if err != nil {
|
||||
s.internalError(w, "complete installation", err)
|
||||
return
|
||||
}
|
||||
s.installed.Store(true)
|
||||
if err := s.ensureLocalAdministrator(r.Context()); err != nil {
|
||||
s.internalError(w, "bootstrap local administrator", err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/auth/start", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func buildDemoUsers() ([]store.DemoUser, error) {
|
||||
specs := []struct {
|
||||
email, name, password, tenant string
|
||||
}{
|
||||
{"interview@nmbs.auth.local", "NMBS Interview", "admin123", "nmbs"},
|
||||
{"interview@ypto.auth.local", "YPTO Interview", "admin123", "ypto"},
|
||||
{"reviewer@nmbs.auth.local", "NMBS Reviewer", "Demo-Nmbs-2026!", "nmbs"},
|
||||
{"reviewer@ypto.auth.local", "YPTO Reviewer", "Demo-Ypto-2026!", "ypto"},
|
||||
}
|
||||
result := make([]store.DemoUser, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
hash, err := password.HashDemo(spec.password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, store.DemoUser{
|
||||
Email: spec.email, DisplayName: spec.name, PasswordHash: hash, TenantSlug: spec.tenant,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Server) installCSRFToken(w http.ResponseWriter) (string, error) {
|
||||
state, err := s.sessions.NewState()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := s.sessions.SignedState("install|" + state)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "go_loose_install_csrf", Value: token, Path: "/install",
|
||||
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"),
|
||||
SameSite: http.SameSiteStrictMode, MaxAge: 900,
|
||||
})
|
||||
return token, nil
|
||||
}
|
||||
63
internal/server/oidc.go
Normal file
63
internal/server/oidc.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/danyel/go-loose/internal/password"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func (s *Server) configureOIDC(ctx context.Context, issuer, clientID, clientSecret string) error {
|
||||
provider, err := oidc.NewProvider(ctx, issuer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("discover OIDC provider: %w", err)
|
||||
}
|
||||
config := &oauth2.Config{
|
||||
ClientID: clientID, ClientSecret: clientSecret,
|
||||
Endpoint: provider.Endpoint(), RedirectURL: s.cfg.OIDCRedirectURL,
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
}
|
||||
s.authMu.Lock()
|
||||
s.oauth = config
|
||||
s.verifier = provider.Verifier(&oidc.Config{ClientID: clientID})
|
||||
s.authMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) oidcConfiguration() (*oauth2.Config, *oidc.IDTokenVerifier) {
|
||||
s.authMu.RLock()
|
||||
defer s.authMu.RUnlock()
|
||||
return s.oauth, s.verifier
|
||||
}
|
||||
|
||||
func (s *Server) hasOIDC() bool {
|
||||
config, verifier := s.oidcConfiguration()
|
||||
return config != nil && verifier != nil
|
||||
}
|
||||
|
||||
func (s *Server) ensureLocalAdministrator(ctx context.Context) error {
|
||||
if !s.cfg.LocalLogin {
|
||||
return nil
|
||||
}
|
||||
dummyHash, err := password.Hash("not-a-real-account-password")
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash password timing value: %w", err)
|
||||
}
|
||||
s.loginGuard.dummyHash = dummyHash
|
||||
if s.cfg.LocalAdminPassword == "" {
|
||||
return nil
|
||||
}
|
||||
passwordHash, err := password.Hash(s.cfg.LocalAdminPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash local administrator password: %w", err)
|
||||
}
|
||||
if err := s.store.EnsurePasswordUser(
|
||||
ctx, s.cfg.LocalAdminEmail, "Local Administrator", passwordHash,
|
||||
s.cfg.BootstrapTenant, s.cfg.BootstrapApp,
|
||||
); err != nil {
|
||||
return fmt.Errorf("bootstrap local administrator: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
22
internal/server/oidc_test.go
Normal file
22
internal/server/oidc_test.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/danyel/go-loose/internal/config"
|
||||
)
|
||||
|
||||
func TestOAuthStateCookieDomain(t *testing.T) {
|
||||
server := &Server{cfg: config.Config{AuthDomain: "auth.local"}}
|
||||
|
||||
tenantRequest := httptest.NewRequest("GET", "http://nmbs.auth.local/auth/start", nil)
|
||||
if domain := server.oauthStateCookieDomain(tenantRequest); domain != "auth.local" {
|
||||
t.Fatalf("tenant cookie domain = %q, want auth.local", domain)
|
||||
}
|
||||
|
||||
localRequest := httptest.NewRequest("GET", "http://localhost:8080/auth/start", nil)
|
||||
if domain := server.oauthStateCookieDomain(localRequest); domain != "" {
|
||||
t.Fatalf("local cookie domain = %q, want host-only cookie", domain)
|
||||
}
|
||||
}
|
||||
137
internal/server/passwordauth.go
Normal file
137
internal/server/passwordauth.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-loose/internal/password"
|
||||
)
|
||||
|
||||
type loginAttempt struct {
|
||||
failures int
|
||||
windowStart time.Time
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
type loginGuard struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]loginAttempt
|
||||
dummyHash string
|
||||
}
|
||||
|
||||
func newLoginGuard() *loginGuard {
|
||||
return &loginGuard{attempts: make(map[string]loginAttempt)}
|
||||
}
|
||||
|
||||
func (g *loginGuard) allowed(key string, now time.Time) bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
attempt := g.attempts[key]
|
||||
return !now.Before(attempt.lockedUntil)
|
||||
}
|
||||
|
||||
func (g *loginGuard) fail(key string, now time.Time) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
attempt := g.attempts[key]
|
||||
if attempt.windowStart.IsZero() || now.Sub(attempt.windowStart) > 10*time.Minute {
|
||||
attempt = loginAttempt{windowStart: now}
|
||||
}
|
||||
attempt.failures++
|
||||
if attempt.failures >= 5 {
|
||||
attempt.lockedUntil = now.Add(15 * time.Minute)
|
||||
}
|
||||
g.attempts[key] = attempt
|
||||
if len(g.attempts) > 10_000 {
|
||||
for itemKey, item := range g.attempts {
|
||||
if now.Sub(item.windowStart) > 30*time.Minute && now.After(item.lockedUntil) {
|
||||
delete(g.attempts, itemKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *loginGuard) success(key string) {
|
||||
g.mu.Lock()
|
||||
delete(g.attempts, key)
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.installed.Load() {
|
||||
writeError(w, http.StatusServiceUnavailable, "Go Loose is not installed")
|
||||
return
|
||||
}
|
||||
if !s.cfg.LocalLogin {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid login request")
|
||||
return
|
||||
}
|
||||
csrfCookie, cookieErr := r.Cookie("go_loose_login_csrf")
|
||||
csrfValue := r.FormValue("csrf_token")
|
||||
csrfPayload, csrfValid := s.sessions.VerifySigned(csrfValue)
|
||||
if cookieErr != nil || subtle.ConstantTimeCompare([]byte(csrfCookie.Value), []byte(csrfValue)) != 1 ||
|
||||
!csrfValid || !strings.HasPrefix(csrfPayload, "login|") {
|
||||
writeError(w, http.StatusForbidden, "invalid login form; reload the login page")
|
||||
return
|
||||
}
|
||||
email := strings.ToLower(strings.TrimSpace(r.Form.Get("email")))
|
||||
passwordValue := r.Form.Get("password")
|
||||
target := "/"
|
||||
if requested := r.Form.Get("return"); requested != "" {
|
||||
validated, err := s.validateLoginReturn(r.Context(), requested)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid client login return URL")
|
||||
return
|
||||
}
|
||||
target = validated
|
||||
}
|
||||
remoteAddress := remoteIP(r.RemoteAddr)
|
||||
guardKey := remoteAddress + "|" + email
|
||||
now := time.Now()
|
||||
if !s.loginGuard.allowed(guardKey, now) {
|
||||
s.recordPasswordLogin(r, nil, email, remoteAddress, false, "rate_limited")
|
||||
writeError(w, http.StatusTooManyRequests, "too many login attempts; try again later")
|
||||
return
|
||||
}
|
||||
user, encoded, err := s.store.PasswordUser(r.Context(), email)
|
||||
valid := err == nil && password.Verify(encoded, passwordValue)
|
||||
if err != nil {
|
||||
password.Verify(s.loginGuard.dummyHash, passwordValue)
|
||||
}
|
||||
if !valid {
|
||||
s.loginGuard.fail(guardKey, now)
|
||||
var userID *string
|
||||
if user.ID != "" {
|
||||
userID = &user.ID
|
||||
}
|
||||
s.recordPasswordLogin(r, userID, email, remoteAddress, false, "invalid_credentials")
|
||||
writeError(w, http.StatusUnauthorized, "invalid email or password")
|
||||
return
|
||||
}
|
||||
s.loginGuard.success(guardKey)
|
||||
s.recordPasswordLogin(r, &user.ID, user.Email, remoteAddress, true, "authenticated")
|
||||
s.finishLogin(w, r, "local:"+user.Email, user.Email, user.DisplayName, target, false)
|
||||
}
|
||||
|
||||
func (s *Server) recordPasswordLogin(r *http.Request, userID *string, email, remoteAddress string, successful bool, reason string) {
|
||||
if err := s.store.RecordLogin(r.Context(), userID, email, remoteAddress, successful, reason); err != nil {
|
||||
s.logger.Error("record password login", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func remoteIP(address string) string {
|
||||
host, _, err := net.SplitHostPort(address)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return address
|
||||
}
|
||||
24
internal/server/passwordauth_test.go
Normal file
24
internal/server/passwordauth_test.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoginGuardLocksAfterFiveFailures(t *testing.T) {
|
||||
guard := newLoginGuard()
|
||||
now := time.Now()
|
||||
for range 5 {
|
||||
if !guard.allowed("address|user@example.com", now) {
|
||||
t.Fatal("guard locked too early")
|
||||
}
|
||||
guard.fail("address|user@example.com", now)
|
||||
}
|
||||
if guard.allowed("address|user@example.com", now) {
|
||||
t.Fatal("guard did not lock after five failures")
|
||||
}
|
||||
guard.success("address|user@example.com")
|
||||
if !guard.allowed("address|user@example.com", now) {
|
||||
t.Fatal("successful login did not clear failures")
|
||||
}
|
||||
}
|
||||
731
internal/server/server.go
Normal file
731
internal/server/server.go
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/danyel/go-loose/internal/config"
|
||||
"github.com/danyel/go-loose/internal/contract"
|
||||
"github.com/danyel/go-loose/internal/key"
|
||||
"github.com/danyel/go-loose/internal/secretbox"
|
||||
"github.com/danyel/go-loose/internal/session"
|
||||
"github.com/danyel/go-loose/internal/store"
|
||||
"github.com/danyel/go-loose/web"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
cfg config.Config
|
||||
store *store.Store
|
||||
db *sql.DB
|
||||
sessions *session.Manager
|
||||
oauth *oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
authMu sync.RWMutex
|
||||
installed atomic.Bool
|
||||
httpClient *http.Client
|
||||
logger *slog.Logger
|
||||
loginGuard *loginGuard
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const claimsKey contextKey = "claims"
|
||||
|
||||
func New(ctx context.Context, cfg config.Config, db *sql.DB, logger *slog.Logger) (*Server, error) {
|
||||
server := &Server{
|
||||
cfg: cfg, store: store.New(db), db: db, logger: logger,
|
||||
sessions: session.New(cfg.SessionSecret, strings.HasPrefix(cfg.BaseURL, "https://")),
|
||||
loginGuard: newLoginGuard(),
|
||||
}
|
||||
server.httpClient = &http.Client{
|
||||
Timeout: cfg.HTTPTimeout,
|
||||
CheckRedirect: func(request *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("too many contract source redirects")
|
||||
}
|
||||
_, err := contract.ValidateSourceURL(request.URL.String(), cfg.AllowPrivateContractURL)
|
||||
return err
|
||||
},
|
||||
}
|
||||
installation, err := server.store.Installation(ctx)
|
||||
if err == nil {
|
||||
secret, decryptErr := secretbox.Decrypt(cfg.SessionSecret, installation.OIDCSecretCiphertext)
|
||||
if decryptErr != nil {
|
||||
return nil, fmt.Errorf("decrypt installed OIDC secret: %w", decryptErr)
|
||||
}
|
||||
if err := server.configureOIDC(ctx, installation.OIDCIssuer, installation.OIDCClientID, secret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
server.installed.Store(true)
|
||||
if err := server.ensureLocalAdministrator(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if !errors.Is(err, store.ErrNotFound) {
|
||||
return nil, fmt.Errorf("load installation: %w", err)
|
||||
} else if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" && cfg.OIDCClientSecret != "" {
|
||||
if err := server.configureOIDC(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
server.installed.Store(true)
|
||||
if err := server.ensureLocalAdministrator(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if cfg.LocalLogin && cfg.LocalAdminPassword != "" {
|
||||
server.installed.Store(true)
|
||||
if err := server.ensureLocalAdministrator(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", s.health)
|
||||
mux.HandleFunc("GET /install", s.installPage)
|
||||
mux.HandleFunc("POST /install", s.install)
|
||||
mux.HandleFunc("GET /login", s.loginPage)
|
||||
mux.HandleFunc("GET /auth/start", s.authStart)
|
||||
mux.HandleFunc("GET /auth/callback", s.authCallback)
|
||||
mux.HandleFunc("GET /auth/dev", s.authDev)
|
||||
mux.HandleFunc("POST /auth/logout", s.authLogout)
|
||||
mux.HandleFunc("POST /auth/password", s.passwordLogin)
|
||||
mux.HandleFunc("GET /assets/{name}", s.asset)
|
||||
mux.HandleFunc("POST /api/v1/authorize", s.authorize)
|
||||
mux.HandleFunc("GET /connect/authorize", s.clientAuthorize)
|
||||
mux.HandleFunc("POST /connect/token", s.clientToken)
|
||||
mux.HandleFunc("GET /connect/userinfo", s.clientUserInfo)
|
||||
mux.HandleFunc("POST /connect/logout", s.clientLogout)
|
||||
mux.HandleFunc("GET /client/login", s.clientLoginPage)
|
||||
|
||||
mux.Handle("GET /", s.requireSession(http.HandlerFunc(s.index)))
|
||||
mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs)))
|
||||
mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi)))
|
||||
mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard)))
|
||||
mux.Handle("POST /api/v1/tenants", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createTenant))))
|
||||
mux.Handle("POST /api/v1/applications", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createApplication))))
|
||||
mux.Handle("POST /api/v1/keys", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createKey))))
|
||||
mux.Handle("POST /api/v1/keys/{id}/revoke", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.revokeKey))))
|
||||
mux.Handle("POST /api/v1/contracts", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createContract))))
|
||||
mux.Handle("POST /api/v1/applications/{id}/client-config", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.configureClient))))
|
||||
mux.Handle("POST /api/v1/users", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.inviteUser))))
|
||||
mux.Handle("PUT /api/v1/users/{id}/access", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.setUserAccess))))
|
||||
mux.Handle("POST /api/v1/users/{id}/approve", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.approveUser))))
|
||||
return s.recover(s.logRequests(s.securityHeaders(mux)))
|
||||
}
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := s.db.PingContext(ctx); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "database unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.installed.Load() {
|
||||
http.Redirect(w, r, "/install", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderManagementLogin(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) clientLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderClientLogin(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
|
||||
allowed, err := s.store.HasManagementAccess(r.Context(), claimsFrom(r).UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "check management access", err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
s.serveEmbedded(w, "waiting-room.html")
|
||||
return
|
||||
}
|
||||
s.serveEmbedded(w, "index.html")
|
||||
}
|
||||
|
||||
func (s *Server) docs(w http.ResponseWriter, _ *http.Request) {
|
||||
s.serveEmbedded(w, "docs.html")
|
||||
}
|
||||
|
||||
func (s *Server) openapi(w http.ResponseWriter, _ *http.Request) {
|
||||
s.serveEmbedded(w, "openapi.json")
|
||||
}
|
||||
|
||||
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
|
||||
name := path.Base(r.PathValue("name"))
|
||||
if name != "style.css" && name != "app.js" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.serveEmbedded(w, name)
|
||||
}
|
||||
|
||||
func (s *Server) serveEmbedded(w http.ResponseWriter, name string) {
|
||||
content, err := web.Files.ReadFile(name)
|
||||
if err != nil {
|
||||
http.Error(w, "asset unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(name)))
|
||||
w.Write(content)
|
||||
}
|
||||
|
||||
func (s *Server) authStart(w http.ResponseWriter, r *http.Request) {
|
||||
target := "/"
|
||||
if requested := r.URL.Query().Get("return"); requested != "" {
|
||||
validated, err := s.validateLoginReturn(r.Context(), requested)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid client login return URL")
|
||||
return
|
||||
}
|
||||
target = validated
|
||||
}
|
||||
state, err := s.newLoginState(target)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not start login")
|
||||
return
|
||||
}
|
||||
oauthConfig, verifier := s.oidcConfiguration()
|
||||
if oauthConfig == nil || verifier == nil {
|
||||
if s.cfg.DevLogin {
|
||||
http.Redirect(w, r, "/auth/dev?state="+url.QueryEscape(state), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusServiceUnavailable, "OIDC is not configured")
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "go_loose_oauth_state", Value: state, Path: "/auth/callback", Domain: s.oauthStateCookieDomain(r),
|
||||
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"),
|
||||
SameSite: http.SameSiteLaxMode, MaxAge: 600,
|
||||
})
|
||||
http.Redirect(w, r, oauthConfig.AuthCodeURL(state), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) authCallback(w http.ResponseWriter, r *http.Request) {
|
||||
stateCookie, cookieErr := r.Cookie("go_loose_oauth_state")
|
||||
if cookieErr != nil || stateCookie.Value != r.URL.Query().Get("state") {
|
||||
writeError(w, http.StatusBadRequest, "invalid OIDC state")
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "go_loose_oauth_state", Value: "", Path: "/auth/callback", Domain: s.oauthStateCookieDomain(r),
|
||||
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"), MaxAge: -1,
|
||||
})
|
||||
target, err := s.loginTargetFromState(r.URL.Query().Get("state"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid OIDC state")
|
||||
return
|
||||
}
|
||||
oauthConfig, verifier := s.oidcConfiguration()
|
||||
if oauthConfig == nil || verifier == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "OIDC is not configured")
|
||||
return
|
||||
}
|
||||
oauthToken, err := oauthConfig.Exchange(r.Context(), r.URL.Query().Get("code"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "OIDC code exchange failed")
|
||||
return
|
||||
}
|
||||
rawIDToken, ok := oauthToken.Extra("id_token").(string)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "identity provider did not return an ID token")
|
||||
return
|
||||
}
|
||||
idToken, err := verifier.Verify(r.Context(), rawIDToken)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "invalid ID token")
|
||||
return
|
||||
}
|
||||
var claims struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil || claims.Subject == "" || claims.Email == "" {
|
||||
writeError(w, http.StatusUnauthorized, "identity claims are incomplete")
|
||||
return
|
||||
}
|
||||
if !s.emailAllowed(claims.Email) {
|
||||
writeError(w, http.StatusForbidden, "email domain is not allowed")
|
||||
return
|
||||
}
|
||||
if claims.Name == "" {
|
||||
claims.Name = claims.Email
|
||||
}
|
||||
s.finishLogin(w, r, claims.Subject, claims.Email, claims.Name, target, true)
|
||||
}
|
||||
|
||||
func (s *Server) authDev(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.cfg.DevLogin {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
target, err := s.loginTargetFromState(r.URL.Query().Get("state"))
|
||||
if r.URL.Query().Get("state") == "" {
|
||||
target, err = "/", nil
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid development login state")
|
||||
return
|
||||
}
|
||||
s.finishLogin(w, r, "dev:"+s.cfg.DevUser, s.cfg.DevUser, "Local Administrator", target, false)
|
||||
}
|
||||
|
||||
func (s *Server) finishLogin(w http.ResponseWriter, r *http.Request, subject, email, name, target string, claimSystemAdministrator bool) {
|
||||
user, err := s.store.UpsertUser(r.Context(), subject, email, name)
|
||||
if err != nil {
|
||||
s.logger.Error("upsert user", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "login failed")
|
||||
return
|
||||
}
|
||||
if claimSystemAdministrator {
|
||||
if _, err := s.store.ClaimFirstSystemAdministrator(r.Context(), user.ID); err != nil {
|
||||
s.logger.Error("claim first system administrator", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "login setup failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
claims := session.Claims{
|
||||
UserID: user.ID, Email: user.Email, DisplayName: user.DisplayName, Management: target == "/",
|
||||
}
|
||||
var sessionErr error
|
||||
if target == "/" {
|
||||
sessionErr = s.sessions.Set(w, claims)
|
||||
} else {
|
||||
sessionErr = s.sessions.SetShared(w, claims, s.cfg.AuthDomain)
|
||||
}
|
||||
if sessionErr != nil {
|
||||
writeError(w, http.StatusInternalServerError, "could not create session")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.sessions.Clear(w)
|
||||
s.sessions.ClearShared(w, s.cfg.AuthDomain)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
claims := claimsFrom(r)
|
||||
allowed, err := s.store.HasManagementAccess(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "check management access", err)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusForbidden, "waiting for administrator approval")
|
||||
return
|
||||
}
|
||||
tenants, err := s.store.ListTenants(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "list tenants", err)
|
||||
return
|
||||
}
|
||||
applications, err := s.store.ListApplications(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "list applications", err)
|
||||
return
|
||||
}
|
||||
keys, err := s.store.ListAPIKeys(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "list API keys", err)
|
||||
return
|
||||
}
|
||||
contracts, err := s.store.ListContracts(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "list contracts", err)
|
||||
return
|
||||
}
|
||||
users, err := s.store.ListManagedUsers(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "list users", err)
|
||||
return
|
||||
}
|
||||
pendingUsers, err := s.store.ListPendingUsers(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "list pending users", err)
|
||||
return
|
||||
}
|
||||
isSystemAdministrator, err := s.store.IsSystemAdministrator(r.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "check system administrator", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"user": claims, "tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
|
||||
"users": users, "pending_users": pendingUsers, "is_system_administrator": isSystemAdministrator,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
||||
isSystemAdministrator, err := s.store.IsSystemAdministrator(r.Context(), claimsFrom(r).UserID)
|
||||
if err != nil {
|
||||
s.internalError(w, "check system administrator", err)
|
||||
return
|
||||
}
|
||||
if !isSystemAdministrator {
|
||||
writeError(w, http.StatusForbidden, "system administrator access required")
|
||||
return
|
||||
}
|
||||
var request struct{ Slug, Name string }
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Slug, request.Name = strings.TrimSpace(request.Slug), strings.TrimSpace(request.Name)
|
||||
if !validSlug(request.Slug) || request.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name and a valid lowercase slug are required")
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateTenant(r.Context(), claimsFrom(r).UserID, request.Slug, request.Name)
|
||||
if err != nil {
|
||||
s.internalError(w, "create tenant", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) createApplication(w http.ResponseWriter, r *http.Request) {
|
||||
var request store.Application
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Name, request.Slug = strings.TrimSpace(request.Name), strings.TrimSpace(request.Slug)
|
||||
if request.TenantID == "" || request.Name == "" || !validSlug(request.Slug) {
|
||||
writeError(w, http.StatusBadRequest, "tenant, name, and a valid lowercase slug are required")
|
||||
return
|
||||
}
|
||||
for i := range request.AllowedHosts {
|
||||
request.AllowedHosts[i] = strings.ToLower(strings.TrimSpace(request.AllowedHosts[i]))
|
||||
if strings.ContainsAny(request.AllowedHosts[i], "/:") {
|
||||
writeError(w, http.StatusBadRequest, "allowed hosts must be hostnames without schemes or ports")
|
||||
return
|
||||
}
|
||||
}
|
||||
item, err := s.store.CreateApplication(r.Context(), claimsFrom(r).UserID, request)
|
||||
if err != nil {
|
||||
s.handleStoreError(w, "create application", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) createKey(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
Name string `json:"name"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Name = strings.TrimSpace(request.Name)
|
||||
if request.ApplicationID == "" || request.Name == "" || (request.ExpiresAt != nil && !request.ExpiresAt.After(time.Now())) {
|
||||
writeError(w, http.StatusBadRequest, "application, name, and a future expiration are required")
|
||||
return
|
||||
}
|
||||
plain, prefix, hash, err := key.Generate()
|
||||
if err != nil {
|
||||
s.internalError(w, "generate API key", err)
|
||||
return
|
||||
}
|
||||
item, err := s.store.CreateAPIKey(r.Context(), claimsFrom(r).UserID, request.ApplicationID, request.Name, prefix, hash, request.ExpiresAt)
|
||||
if err != nil {
|
||||
s.handleStoreError(w, "create API key", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"key": item, "secret": plain})
|
||||
}
|
||||
|
||||
func (s *Server) revokeKey(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.store.RevokeAPIKey(r.Context(), claimsFrom(r).UserID, r.PathValue("id")); err != nil {
|
||||
s.handleStoreError(w, "revoke API key", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) authorize(w http.ResponseWriter, r *http.Request) {
|
||||
plain := strings.TrimSpace(r.Header.Get("X-API-Key"))
|
||||
if plain == "" {
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authorization, "Bearer ") {
|
||||
plain = strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
|
||||
}
|
||||
}
|
||||
if key.ValidateFormat(plain) != nil {
|
||||
writeError(w, http.StatusUnauthorized, "a valid X-API-Key header is required")
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Tenant string `json:"tenant"`
|
||||
Application string `json:"application"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Method = strings.ToUpper(strings.TrimSpace(request.Method))
|
||||
request.Host = strings.ToLower(strings.TrimSpace(request.Host))
|
||||
if request.Application == "" || request.Method == "" || !strings.HasPrefix(request.Path, "/") {
|
||||
writeError(w, http.StatusBadRequest, "application, method, and an absolute path are required")
|
||||
return
|
||||
}
|
||||
result, err := s.store.Authorize(r.Context(), key.Hash(plain), request.Tenant, request.Application, request.Method, request.Path, request.Host)
|
||||
if err != nil {
|
||||
s.internalError(w, "authorize request", err)
|
||||
return
|
||||
}
|
||||
if !result.Allowed {
|
||||
writeJSON(w, http.StatusForbidden, result)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (s *Server) createContract(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceURL string `json:"source_url"`
|
||||
Document string `json:"document"`
|
||||
}
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
var content []byte
|
||||
var sourceURL *string
|
||||
if request.SourceType == "url" {
|
||||
parsed, err := contract.ValidateSourceURL(request.SourceURL, s.cfg.AllowPrivateContractURL)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
content, err = s.fetchContract(r.Context(), parsed)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
value := parsed.String()
|
||||
sourceURL = &value
|
||||
} else {
|
||||
content = []byte(request.Document)
|
||||
if request.SourceURL != "" {
|
||||
sourceURL = &request.SourceURL
|
||||
}
|
||||
}
|
||||
if len(content) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "an OpenAPI document or source URL is required")
|
||||
return
|
||||
}
|
||||
document, err := contract.Parse(content)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
item, err := s.store.SaveContract(r.Context(), claimsFrom(r).UserID, request.ApplicationID, document, sourceURL)
|
||||
if err != nil {
|
||||
s.handleStoreError(w, "save contract", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, item)
|
||||
}
|
||||
|
||||
func (s *Server) fetchContract(ctx context.Context, source *url.URL) ([]byte, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source.String(), nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("could not create contract request")
|
||||
}
|
||||
request.Header.Set("Accept", "application/json, application/yaml, text/yaml")
|
||||
response, err := s.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch contract: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("contract source returned HTTP %d", response.StatusCode)
|
||||
}
|
||||
content, err := io.ReadAll(io.LimitReader(response.Body, 5<<20+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read contract: %w", err)
|
||||
}
|
||||
if len(content) > 5<<20 {
|
||||
return nil, errors.New("contract exceeds 5 MiB")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (s *Server) requireSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.installed.Load() {
|
||||
http.Redirect(w, r, "/install", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
claims, err := s.sessions.Get(r)
|
||||
if err != nil || !claims.Management {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/openapi.json" {
|
||||
writeError(w, http.StatusUnauthorized, "authentication required")
|
||||
} else {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims)))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireSameOrigin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
source := r.Header.Get("Origin")
|
||||
if source == "" {
|
||||
source = r.Header.Get("Referer")
|
||||
}
|
||||
sourceURL, sourceErr := url.Parse(source)
|
||||
baseURL, baseErr := url.Parse(s.cfg.BaseURL)
|
||||
if sourceErr != nil || baseErr != nil || sourceURL.Scheme != baseURL.Scheme || sourceURL.Host != baseURL.Host {
|
||||
writeError(w, http.StatusForbidden, "same-origin request required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline' https://unpkg.com; script-src 'self' 'unsafe-inline' https://unpkg.com; connect-src 'self'; img-src 'self' data:; font-src 'self' data:")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
s.logger.Info("http request", "method", r.Method, "path", r.URL.Path, "duration", time.Since(start))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) recover(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
s.logger.Error("panic recovered", "value", recovered)
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) emailAllowed(email string) bool {
|
||||
if len(s.cfg.AllowedEmailDomains) == 0 {
|
||||
return true
|
||||
}
|
||||
parts := strings.Split(strings.ToLower(email), "@")
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
for _, domain := range s.cfg.AllowedEmailDomains {
|
||||
if parts[1] == domain {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) oauthStateCookieDomain(r *http.Request) string {
|
||||
host := strings.ToLower(r.Host)
|
||||
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = parsedHost
|
||||
}
|
||||
if host == s.cfg.AuthDomain || strings.HasSuffix(host, "."+s.cfg.AuthDomain) {
|
||||
return s.cfg.AuthDomain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) internalError(w http.ResponseWriter, operation string, err error) {
|
||||
s.logger.Error(operation, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal server error")
|
||||
}
|
||||
|
||||
func (s *Server) handleStoreError(w http.ResponseWriter, operation string, err error) {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "resource not found or permission denied")
|
||||
return
|
||||
}
|
||||
s.internalError(w, operation, err)
|
||||
}
|
||||
|
||||
func claimsFrom(r *http.Request) session.Claims {
|
||||
return r.Context().Value(claimsKey).(session.Claims)
|
||||
}
|
||||
|
||||
func validSlug(value string) bool {
|
||||
if len(value) < 2 || len(value) > 63 || value[0] < 'a' || value[0] > 'z' {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if (character < 'a' || character > 'z') && (character < '0' || character > '9') && character != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON request")
|
||||
return false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
writeError(w, http.StatusBadRequest, "request must contain one JSON object")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(value); err != nil {
|
||||
slog.Error("encode response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
151
internal/server/tenantauth.go
Normal file
151
internal/server/tenantauth.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"html"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/danyel/go-loose/web"
|
||||
)
|
||||
|
||||
func (s *Server) tenantAuthHost(tenantSlug string) string {
|
||||
host := tenantSlug + "." + s.cfg.AuthDomain
|
||||
if parsed, err := url.Parse(s.cfg.BaseURL); err == nil && parsed.Port() != "" {
|
||||
host = net.JoinHostPort(host, parsed.Port())
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *Server) tenantAuthURL(tenantSlug, requestURI string) string {
|
||||
base, _ := url.Parse(s.cfg.BaseURL)
|
||||
return base.Scheme + "://" + s.tenantAuthHost(tenantSlug) + requestURI
|
||||
}
|
||||
|
||||
func (s *Server) validateLoginReturn(ctx context.Context, raw string) (string, error) {
|
||||
target, err := url.Parse(raw)
|
||||
if err != nil || !target.IsAbs() || target.Path != "/connect/authorize" || target.Fragment != "" || target.User != nil {
|
||||
return "", errors.New("invalid return URL")
|
||||
}
|
||||
base, err := url.Parse(s.cfg.BaseURL)
|
||||
if err != nil || target.Scheme != base.Scheme {
|
||||
return "", errors.New("invalid return scheme")
|
||||
}
|
||||
clientID, redirectURI := target.Query().Get("client_id"), target.Query().Get("redirect_uri")
|
||||
app, err := s.store.ValidateClientRedirect(ctx, clientID, redirectURI)
|
||||
if err != nil || !strings.EqualFold(target.Host, s.tenantAuthHost(app.TenantSlug)) {
|
||||
return "", errors.New("return URL does not match client tenant")
|
||||
}
|
||||
return target.String(), nil
|
||||
}
|
||||
|
||||
func (s *Server) newLoginState(target string) (string, error) {
|
||||
nonce, err := s.sessions.NewState()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload := nonce + "|" + base64.RawURLEncoding.EncodeToString([]byte(target))
|
||||
return s.sessions.SignedState(payload), nil
|
||||
}
|
||||
|
||||
func (s *Server) loginTargetFromState(state string) (string, error) {
|
||||
payload, ok := s.sessions.VerifySigned(state)
|
||||
if !ok {
|
||||
return "", errors.New("invalid signed state")
|
||||
}
|
||||
parts := strings.SplitN(payload, "|", 2)
|
||||
if len(parts) != 2 || parts[0] == "" {
|
||||
return "", errors.New("invalid state payload")
|
||||
}
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return "", errors.New("invalid state target")
|
||||
}
|
||||
target := string(decoded)
|
||||
if target == "/" {
|
||||
return target, nil
|
||||
}
|
||||
return s.validateLoginReturn(context.Background(), target)
|
||||
}
|
||||
|
||||
func (s *Server) renderClientLogin(w http.ResponseWriter, r *http.Request) {
|
||||
clientID := r.URL.Query().Get("client_id")
|
||||
returnURL := r.URL.Query().Get("return")
|
||||
app, err := s.store.ClientByID(r.Context(), clientID)
|
||||
if err != nil || !strings.EqualFold(r.Host, s.tenantAuthHost(app.TenantSlug)) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
validatedReturn, err := s.validateLoginReturn(r.Context(), returnURL)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid client login request")
|
||||
return
|
||||
}
|
||||
content, err := web.Files.ReadFile("client-login.html")
|
||||
if err != nil {
|
||||
http.Error(w, "login page unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
loginURL := "/auth/start?return=" + url.QueryEscape(validatedReturn)
|
||||
csrfToken, err := s.loginCSRFToken(w)
|
||||
if err != nil {
|
||||
http.Error(w, "could not prepare login", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
page := strings.NewReplacer(
|
||||
"{{TENANT_NAME}}", html.EscapeString(app.TenantName),
|
||||
"{{APPLICATION_NAME}}", html.EscapeString(app.Name),
|
||||
"{{LOGIN_URL}}", html.EscapeString(loginURL),
|
||||
"{{RETURN_URL}}", html.EscapeString(validatedReturn),
|
||||
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
|
||||
"{{LOCAL_STYLE}}", hiddenStyle(!s.cfg.LocalLogin),
|
||||
"{{SSO_STYLE}}", hiddenStyle(!s.hasOIDC()),
|
||||
).Replace(string(content))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(page))
|
||||
}
|
||||
|
||||
func (s *Server) renderManagementLogin(w http.ResponseWriter, _ *http.Request) {
|
||||
content, err := web.Files.ReadFile("login.html")
|
||||
if err != nil {
|
||||
http.Error(w, "login page unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
csrfToken, err := s.loginCSRFToken(w)
|
||||
if err != nil {
|
||||
http.Error(w, "could not prepare login", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
page := strings.NewReplacer(
|
||||
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
|
||||
"{{LOCAL_STYLE}}", hiddenStyle(!s.cfg.LocalLogin),
|
||||
"{{SSO_STYLE}}", hiddenStyle(!s.hasOIDC()),
|
||||
).Replace(string(content))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(page))
|
||||
}
|
||||
|
||||
func (s *Server) loginCSRFToken(w http.ResponseWriter) (string, error) {
|
||||
state, err := s.sessions.NewState()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := s.sessions.SignedState("login|" + state)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "go_loose_login_csrf", Value: token, Path: "/auth/password",
|
||||
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"),
|
||||
SameSite: http.SameSiteStrictMode, MaxAge: 600,
|
||||
})
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func hiddenStyle(hidden bool) string {
|
||||
if hidden {
|
||||
return "display:none"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
136
internal/server/useradmin.go
Normal file
136
internal/server/useradmin.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/danyel/go-loose/internal/key"
|
||||
"github.com/danyel/go-loose/internal/password"
|
||||
)
|
||||
|
||||
func (s *Server) configureClient(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
}
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if len(request.RedirectURIs) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "at least one redirect URI is required")
|
||||
return
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for index, raw := range request.RedirectURIs {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.Fragment != "" || parsed.User != nil {
|
||||
writeError(w, http.StatusBadRequest, "redirect URIs must be absolute URLs without fragments or credentials")
|
||||
return
|
||||
}
|
||||
localHTTP := (s.cfg.DevLogin || s.cfg.LocalLogin) && parsed.Scheme == "http" &&
|
||||
(parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
|
||||
if parsed.Scheme != "https" && !localHTTP {
|
||||
writeError(w, http.StatusBadRequest, "redirect URIs require HTTPS except localhost in development")
|
||||
return
|
||||
}
|
||||
request.RedirectURIs[index] = parsed.String()
|
||||
if seen[parsed.String()] {
|
||||
writeError(w, http.StatusBadRequest, "redirect URIs must be unique")
|
||||
return
|
||||
}
|
||||
seen[parsed.String()] = true
|
||||
}
|
||||
secret, _, hash, err := key.GenerateToken("glc_secret_")
|
||||
if err != nil {
|
||||
s.internalError(w, "generate client secret", err)
|
||||
return
|
||||
}
|
||||
app, err := s.store.ConfigureClient(r.Context(), claimsFrom(r).UserID, r.PathValue("id"), request.RedirectURIs, hash)
|
||||
if err != nil {
|
||||
s.handleStoreError(w, "configure client login", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"application": app, "client_secret": secret})
|
||||
}
|
||||
|
||||
func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
address, err := mail.ParseAddress(strings.TrimSpace(request.Email))
|
||||
if err != nil || address.Address != request.Email {
|
||||
writeError(w, http.StatusBadRequest, "a valid plain email address is required")
|
||||
return
|
||||
}
|
||||
request.Email = strings.ToLower(address.Address)
|
||||
request.DisplayName = strings.TrimSpace(request.DisplayName)
|
||||
if request.DisplayName == "" {
|
||||
request.DisplayName = request.Email
|
||||
}
|
||||
if request.Role != "admin" && request.Role != "viewer" {
|
||||
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
|
||||
return
|
||||
}
|
||||
passwordHash, err := password.Hash(request.Password)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.Role, passwordHash)
|
||||
if err != nil {
|
||||
s.handleStoreError(w, "invite user", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
Role string `json:"role"`
|
||||
ApplicationIDs []string `json:"application_ids"`
|
||||
}
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
|
||||
writeError(w, http.StatusBadRequest, "invalid role")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.Role, request.ApplicationIDs); err != nil {
|
||||
s.handleStoreError(w, "update user access", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
Role string `json:"role"`
|
||||
ApplicationIDs []string `json:"application_ids"`
|
||||
}
|
||||
if !decodeJSON(w, r, &request) {
|
||||
return
|
||||
}
|
||||
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
|
||||
writeError(w, http.StatusBadRequest, "invalid role")
|
||||
return
|
||||
}
|
||||
if err := s.store.ApprovePendingUser(
|
||||
r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"),
|
||||
request.Role, request.ApplicationIDs,
|
||||
); err != nil {
|
||||
s.handleStoreError(w, "approve pending user", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
125
internal/session/session.go
Normal file
125
internal/session/session.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const cookieName = "go_loose_session"
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"uid"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"name"`
|
||||
Management bool `json:"management"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
secure bool
|
||||
}
|
||||
|
||||
func New(secret string, secure bool) *Manager {
|
||||
return &Manager{secret: []byte(secret), secure: secure}
|
||||
}
|
||||
|
||||
func (m *Manager) Set(w http.ResponseWriter, claims Claims) error {
|
||||
return m.set(w, claims, "")
|
||||
}
|
||||
|
||||
func (m *Manager) SetShared(w http.ResponseWriter, claims Claims, domain string) error {
|
||||
return m.set(w, claims, domain)
|
||||
}
|
||||
|
||||
func (m *Manager) set(w http.ResponseWriter, claims Claims, domain string) error {
|
||||
claims.ExpiresAt = time.Now().Add(12 * time.Hour).Unix()
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(payload)
|
||||
value := encoded + "." + m.sign(encoded)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName, Value: value, Path: "/", HttpOnly: true, Secure: m.secure,
|
||||
SameSite: http.SameSiteLaxMode, MaxAge: int((12 * time.Hour).Seconds()), Domain: domain,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Get(r *http.Request) (Claims, error) {
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
parts := strings.Split(cookie.Value, ".")
|
||||
if len(parts) != 2 || !hmac.Equal([]byte(parts[1]), []byte(m.sign(parts[0]))) {
|
||||
return Claims{}, errors.New("invalid session signature")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return Claims{}, errors.New("invalid session encoding")
|
||||
}
|
||||
var claims Claims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return Claims{}, errors.New("invalid session payload")
|
||||
}
|
||||
if claims.UserID == "" || time.Now().Unix() >= claims.ExpiresAt {
|
||||
return Claims{}, errors.New("session expired")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Clear(w http.ResponseWriter) {
|
||||
m.clear(w, "")
|
||||
}
|
||||
|
||||
func (m *Manager) ClearShared(w http.ResponseWriter, domain string) {
|
||||
m.clear(w, domain)
|
||||
}
|
||||
|
||||
func (m *Manager) clear(w http.ResponseWriter, domain string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName, Value: "", Path: "/", HttpOnly: true, Secure: m.secure,
|
||||
SameSite: http.SameSiteLaxMode, MaxAge: -1, Domain: domain,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) NewState() (string, error) {
|
||||
value := make([]byte, 24)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func (m *Manager) SignedState(state string) string {
|
||||
return state + "." + m.sign(state)
|
||||
}
|
||||
|
||||
func (m *Manager) VerifyState(value string) bool {
|
||||
_, ok := m.VerifySigned(value)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (m *Manager) VerifySigned(value string) (string, bool) {
|
||||
index := strings.LastIndexByte(value, '.')
|
||||
if index <= 0 || index == len(value)-1 {
|
||||
return "", false
|
||||
}
|
||||
payload, signature := value[:index], value[index+1:]
|
||||
return payload, hmac.Equal([]byte(signature), []byte(m.sign(payload)))
|
||||
}
|
||||
|
||||
func (m *Manager) sign(value string) string {
|
||||
mac := hmac.New(sha256.New, m.secret)
|
||||
mac.Write([]byte(value))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
43
internal/session/session_test.go
Normal file
43
internal/session/session_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package session
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionRoundTrip(t *testing.T) {
|
||||
manager := New("01234567890123456789012345678901", false)
|
||||
recorder := httptest.NewRecorder()
|
||||
if err := manager.Set(recorder, Claims{UserID: "user-1", Email: "a@example.com"}); err != nil {
|
||||
t.Fatalf("Set() error = %v", err)
|
||||
}
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.AddCookie(recorder.Result().Cookies()[0])
|
||||
claims, err := manager.Get(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v", err)
|
||||
}
|
||||
if claims.UserID != "user-1" {
|
||||
t.Fatalf("UserID = %q", claims.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsTamperedSession(t *testing.T) {
|
||||
manager := New("01234567890123456789012345678901", false)
|
||||
request := httptest.NewRequest("GET", "/", nil)
|
||||
request.AddCookie(&http.Cookie{Name: cookieName, Value: "tampered.value"})
|
||||
if _, err := manager.Get(request); err == nil {
|
||||
t.Fatal("expected tampered session to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignedValueAllowsDots(t *testing.T) {
|
||||
manager := New("01234567890123456789012345678901", false)
|
||||
value := "/connect/authorize?redirect_uri=https://app.example.test/callback"
|
||||
signed := manager.SignedState(value)
|
||||
got, ok := manager.VerifySigned(signed)
|
||||
if !ok || got != value {
|
||||
t.Fatalf("VerifySigned() = %q, %v", got, ok)
|
||||
}
|
||||
}
|
||||
299
internal/store/clientauth.go
Normal file
299
internal/store/clientauth.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ManagedUser struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
ApplicationIDs []string `json:"application_ids"`
|
||||
}
|
||||
|
||||
type ClientApplication struct {
|
||||
ID string
|
||||
TenantID string
|
||||
TenantSlug string
|
||||
TenantName string
|
||||
Application string
|
||||
Name string
|
||||
ClientID string
|
||||
}
|
||||
|
||||
type ClientUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
Application string `json:"application"`
|
||||
}
|
||||
|
||||
func (s *Store) ListManagedUsers(ctx context.Context, administratorID string) ([]ManagedUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT u.id, m.tenant_id, u.email, u.display_name, m.role,
|
||||
CASE WHEN u.subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END,
|
||||
COALESCE((
|
||||
SELECT json_agg(ua.application_id ORDER BY ua.application_id)
|
||||
FROM user_application_access ua
|
||||
JOIN applications a ON a.id = ua.application_id
|
||||
WHERE ua.user_id = u.id AND a.tenant_id = m.tenant_id
|
||||
), '[]'::json)
|
||||
FROM users u
|
||||
JOIN memberships m ON m.user_id = u.id
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM memberships own
|
||||
WHERE own.tenant_id = m.tenant_id AND own.user_id = $1
|
||||
AND own.role IN ('owner', 'admin')
|
||||
)
|
||||
ORDER BY u.email, m.tenant_id`, administratorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]ManagedUser, 0)
|
||||
for rows.Next() {
|
||||
var user ManagedUser
|
||||
var applicationIDs []byte
|
||||
if err := rows.Scan(&user.ID, &user.TenantID, &user.Email, &user.DisplayName, &user.Role, &user.Status, &applicationIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(applicationIDs, &user.ApplicationIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, user)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) InviteUser(ctx context.Context, administratorID, tenantID, email, displayName, role, passwordHash string) (ManagedUser, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ManagedUser{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var permitted bool
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM memberships
|
||||
WHERE tenant_id = $1 AND user_id = $2 AND role IN ('owner', 'admin')
|
||||
)`, tenantID, administratorID).Scan(&permitted); err != nil {
|
||||
return ManagedUser{}, err
|
||||
}
|
||||
if !permitted {
|
||||
return ManagedUser{}, ErrNotFound
|
||||
}
|
||||
var user ManagedUser
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO users(subject, email, display_name, password_hash, password_changed_at)
|
||||
VALUES ('invited:' || gen_random_uuid()::text, lower($1), $2, $3, now())
|
||||
ON CONFLICT ((lower(email))) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
password_changed_at = now()
|
||||
RETURNING id, email, display_name,
|
||||
CASE WHEN subject LIKE 'invited:%' THEN 'invited' ELSE 'active' END`,
|
||||
email, displayName, passwordHash).Scan(&user.ID, &user.Email, &user.DisplayName, &user.Status)
|
||||
if err != nil {
|
||||
return ManagedUser{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||
tenantID, user.ID, role); err != nil {
|
||||
return ManagedUser{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ManagedUser{}, err
|
||||
}
|
||||
user.TenantID, user.Role, user.ApplicationIDs = tenantID, role, []string{}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetUserAccess(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
UPDATE memberships target SET role = $4
|
||||
FROM memberships administrator
|
||||
WHERE target.tenant_id = $1 AND target.user_id = $2
|
||||
AND administrator.tenant_id = target.tenant_id
|
||||
AND administrator.user_id = $3 AND administrator.role IN ('owner', 'admin')`,
|
||||
tenantID, userID, administratorID, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
DELETE FROM user_application_access ua
|
||||
USING applications a
|
||||
WHERE ua.application_id = a.id AND a.tenant_id = $1 AND ua.user_id = $2`,
|
||||
tenantID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, applicationID := range applicationIDs {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_application_access(application_id, user_id, granted_by)
|
||||
SELECT id, $2, $3 FROM applications WHERE id = $1 AND tenant_id = $4
|
||||
ON CONFLICT DO NOTHING`, applicationID, userID, administratorID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ConfigureClient(ctx context.Context, administratorID, applicationID string, redirectURIs []string, secretHash []byte) (Application, error) {
|
||||
var app Application
|
||||
var encoded []byte
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
UPDATE applications a
|
||||
SET redirect_uris = $3, client_secret_hash = $4, updated_at = now()
|
||||
FROM memberships m
|
||||
WHERE a.id = $1 AND m.tenant_id = a.tenant_id AND m.user_id = $2
|
||||
AND m.role IN ('owner', 'admin')
|
||||
RETURNING a.id, a.client_id, to_json(a.redirect_uris), true`,
|
||||
applicationID, administratorID, redirectURIs, secretHash).
|
||||
Scan(&app.ID, &app.ClientID, &encoded, &app.ClientReady)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Application{}, ErrNotFound
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(encoded, &app.RedirectURIs)
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (s *Store) ClientForAuthorization(ctx context.Context, clientID, redirectURI, userID string) (ClientApplication, error) {
|
||||
var app ClientApplication
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT a.id, a.tenant_id, t.slug, t.name, a.slug, a.name, a.client_id
|
||||
FROM applications a
|
||||
JOIN tenants t ON t.id = a.tenant_id
|
||||
JOIN user_application_access ua ON ua.application_id = a.id AND ua.user_id = $3
|
||||
WHERE a.client_id = $1 AND $2 = ANY(a.redirect_uris)
|
||||
AND a.client_secret_hash IS NOT NULL`,
|
||||
clientID, redirectURI, userID).
|
||||
Scan(&app.ID, &app.TenantID, &app.TenantSlug, &app.TenantName, &app.Application, &app.Name, &app.ClientID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ClientApplication{}, ErrNotFound
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (s *Store) ValidateClientRedirect(ctx context.Context, clientID, redirectURI string) (ClientApplication, error) {
|
||||
var app ClientApplication
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT a.id, a.tenant_id, t.slug, t.name, a.slug, a.name, a.client_id
|
||||
FROM applications a
|
||||
JOIN tenants t ON t.id = a.tenant_id
|
||||
WHERE a.client_id = $1 AND $2 = ANY(a.redirect_uris)
|
||||
AND a.client_secret_hash IS NOT NULL`,
|
||||
clientID, redirectURI).
|
||||
Scan(&app.ID, &app.TenantID, &app.TenantSlug, &app.TenantName, &app.Application, &app.Name, &app.ClientID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ClientApplication{}, ErrNotFound
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (s *Store) ClientByID(ctx context.Context, clientID string) (ClientApplication, error) {
|
||||
var app ClientApplication
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT a.id, a.tenant_id, t.slug, t.name, a.slug, a.name, a.client_id
|
||||
FROM applications a
|
||||
JOIN tenants t ON t.id = a.tenant_id
|
||||
WHERE a.client_id = $1 AND a.client_secret_hash IS NOT NULL`,
|
||||
clientID).
|
||||
Scan(&app.ID, &app.TenantID, &app.TenantSlug, &app.TenantName, &app.Application, &app.Name, &app.ClientID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ClientApplication{}, ErrNotFound
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateAuthorizationCode(ctx context.Context, applicationID, userID, redirectURI string, codeHash []byte) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO client_authorization_codes(code_hash, application_id, user_id, redirect_uri, expires_at)
|
||||
VALUES ($1, $2, $3, $4, now() + interval '2 minutes')`,
|
||||
codeHash, applicationID, userID, redirectURI)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ExchangeAuthorizationCode(ctx context.Context, clientID, redirectURI string, secretHash, codeHash, tokenHash []byte) (ClientUser, time.Time, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ClientUser{}, time.Time{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var user ClientUser
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
UPDATE client_authorization_codes c SET used_at = now()
|
||||
FROM applications a, tenants t, users u
|
||||
WHERE c.code_hash = $1 AND c.used_at IS NULL AND c.expires_at > now()
|
||||
AND c.redirect_uri = $2 AND a.id = c.application_id
|
||||
AND a.client_id = $3 AND a.client_secret_hash = $4
|
||||
AND t.id = a.tenant_id AND u.id = c.user_id
|
||||
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`,
|
||||
codeHash, redirectURI, clientID, secretHash).
|
||||
Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ClientUser{}, time.Time{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return ClientUser{}, time.Time{}, err
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(12 * time.Hour)
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO client_user_sessions(token_hash, application_id, user_id, expires_at)
|
||||
VALUES ($1, $2, $3, $4)`, tokenHash, user.ApplicationID, user.ID, expiresAt); err != nil {
|
||||
return ClientUser{}, time.Time{}, err
|
||||
}
|
||||
return user, expiresAt, tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ClientUserByToken(ctx context.Context, tokenHash []byte) (ClientUser, error) {
|
||||
var user ClientUser
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
UPDATE client_user_sessions s SET last_used_at = now()
|
||||
FROM applications a, tenants t, users u, user_application_access ua
|
||||
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
|
||||
AND a.id = s.application_id AND t.id = a.tenant_id AND u.id = s.user_id
|
||||
AND ua.application_id = a.id AND ua.user_id = u.id
|
||||
RETURNING u.id, u.email, u.display_name, t.id, t.slug, a.id, a.slug`,
|
||||
tokenHash).Scan(&user.ID, &user.Email, &user.DisplayName, &user.TenantID, &user.TenantSlug, &user.ApplicationID, &user.Application)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ClientUser{}, ErrNotFound
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (s *Store) RevokeClientSession(ctx context.Context, tokenHash []byte) error {
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
UPDATE client_user_sessions SET revoked_at = now()
|
||||
WHERE token_hash = $1 AND revoked_at IS NULL`, tokenHash)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
207
internal/store/installation.go
Normal file
207
internal/store/installation.go
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
type Installation struct {
|
||||
OIDCIssuer string
|
||||
OIDCClientID string
|
||||
OIDCSecretCiphertext []byte
|
||||
SeededDemo bool
|
||||
}
|
||||
|
||||
type DemoUser struct {
|
||||
Email string
|
||||
DisplayName string
|
||||
PasswordHash string
|
||||
TenantSlug string
|
||||
}
|
||||
|
||||
func (s *Store) Installation(ctx context.Context) (Installation, error) {
|
||||
var installation Installation
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, seeded_demo
|
||||
FROM installation_settings WHERE singleton = true`).
|
||||
Scan(&installation.OIDCIssuer, &installation.OIDCClientID, &installation.OIDCSecretCiphertext, &installation.SeededDemo)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Installation{}, ErrNotFound
|
||||
}
|
||||
return installation, err
|
||||
}
|
||||
|
||||
func (s *Store) Install(ctx context.Context, installation Installation, demoUsers []DemoUser) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO installation_settings(singleton, oidc_issuer, oidc_client_id, oidc_client_secret_ciphertext, seeded_demo)
|
||||
VALUES (true, $1, $2, $3, $4)`,
|
||||
installation.OIDCIssuer, installation.OIDCClientID, installation.OIDCSecretCiphertext, installation.SeededDemo); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(demoUsers) == 0 {
|
||||
return tx.Commit()
|
||||
}
|
||||
for _, tenantSlug := range []string{"nmbs", "ypto"} {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tenants(slug, name) VALUES ($1, upper($1))
|
||||
ON CONFLICT (slug) DO NOTHING`, tenantSlug); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO applications(tenant_id, slug, name, description)
|
||||
SELECT id, 'guess', 'Guess', 'Seeded Guess interview application'
|
||||
FROM tenants WHERE slug = $1
|
||||
ON CONFLICT (tenant_id, slug) DO NOTHING`, tenantSlug); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, demo := range demoUsers {
|
||||
var userID string
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO users(subject, email, display_name, password_hash, password_changed_at)
|
||||
VALUES ('demo:' || lower($1), lower($1), $2, $3, now())
|
||||
ON CONFLICT ((lower(email))) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
password_changed_at = now()
|
||||
RETURNING id`, demo.Email, demo.DisplayName, demo.PasswordHash).Scan(&userID); err != nil {
|
||||
return err
|
||||
}
|
||||
var tenantID string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT id FROM tenants WHERE slug = $1`, demo.TenantSlug).Scan(&tenantID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'admin')
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = 'admin'`, tenantID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_application_access(application_id, user_id, granted_by)
|
||||
SELECT id, $2, $2 FROM applications WHERE tenant_id = $1 AND slug = 'guess'
|
||||
ON CONFLICT DO NOTHING`, tenantID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ClaimFirstSystemAdministrator(ctx context.Context, userID string) (bool, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(554628171917)`); err != nil {
|
||||
return false, err
|
||||
}
|
||||
var exists bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM system_administrators)`).Scan(&exists); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if exists {
|
||||
return false, tx.Commit()
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO system_administrators(user_id) VALUES ($1)`, userID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO memberships(tenant_id, user_id, role)
|
||||
SELECT id, $1, 'owner' FROM tenants ON CONFLICT DO NOTHING`, userID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_application_access(application_id, user_id, granted_by)
|
||||
SELECT id, $1, $1 FROM applications ON CONFLICT DO NOTHING`, userID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) HasManagementAccess(ctx context.Context, userID string) (bool, error) {
|
||||
var allowed bool
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)
|
||||
OR EXISTS(SELECT 1 FROM memberships WHERE user_id = $1)`, userID).Scan(&allowed)
|
||||
return allowed, err
|
||||
}
|
||||
|
||||
func (s *Store) IsSystemAdministrator(ctx context.Context, userID string) (bool, error) {
|
||||
var allowed bool
|
||||
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM system_administrators WHERE user_id = $1)`, userID).Scan(&allowed)
|
||||
return allowed, err
|
||||
}
|
||||
|
||||
func (s *Store) ListPendingUsers(ctx context.Context, administratorID string) ([]User, error) {
|
||||
isAdministrator, err := s.IsSystemAdministrator(ctx, administratorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isAdministrator {
|
||||
return []User{}, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT u.id, u.email, u.display_name
|
||||
FROM users u
|
||||
WHERE NOT EXISTS (SELECT 1 FROM memberships m WHERE m.user_id = u.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM system_administrators sa WHERE sa.user_id = u.id)
|
||||
ORDER BY u.created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]User, 0)
|
||||
for rows.Next() {
|
||||
var user User
|
||||
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, user)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ApprovePendingUser(ctx context.Context, administratorID, tenantID, userID, role string, applicationIDs []string) error {
|
||||
isAdministrator, err := s.IsSystemAdministrator(ctx, administratorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isAdministrator {
|
||||
return ErrNotFound
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO memberships(tenant_id, user_id, role)
|
||||
SELECT $1, id, $3 FROM users WHERE id = $2
|
||||
ON CONFLICT (tenant_id, user_id) DO UPDATE SET role = EXCLUDED.role`,
|
||||
tenantID, userID, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
for _, applicationID := range applicationIDs {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_application_access(application_id, user_id, granted_by)
|
||||
SELECT id, $2, $3 FROM applications WHERE id = $1 AND tenant_id = $4
|
||||
ON CONFLICT DO NOTHING`, applicationID, userID, administratorID, tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, _ := result.RowsAffected(); count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
43
internal/store/passwordauth.go
Normal file
43
internal/store/passwordauth.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (s *Store) EnsurePasswordUser(ctx context.Context, email, displayName, passwordHash, tenantSlug, appSlug string) error {
|
||||
user, err := s.UpsertUser(ctx, "local:"+email, email, displayName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET password_hash = $2, password_changed_at = now()
|
||||
WHERE id = $1 AND password_hash IS NULL`, user.ID, passwordHash); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Bootstrap(ctx, user.ID, tenantSlug, appSlug)
|
||||
}
|
||||
|
||||
func (s *Store) PasswordUser(ctx context.Context, email string) (User, string, error) {
|
||||
var user User
|
||||
var passwordHash sql.NullString
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, email, display_name, password_hash
|
||||
FROM users
|
||||
WHERE lower(email) = lower($1)`, email).
|
||||
Scan(&user.ID, &user.Email, &user.DisplayName, &passwordHash)
|
||||
if errors.Is(err, sql.ErrNoRows) || (err == nil && !passwordHash.Valid) {
|
||||
return User{}, "", ErrNotFound
|
||||
}
|
||||
return user, passwordHash.String, err
|
||||
}
|
||||
|
||||
func (s *Store) RecordLogin(ctx context.Context, userID *string, email, remoteAddress string, successful bool, reason string) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO user_login_events(user_id, email, remote_address, successful, reason)
|
||||
VALUES ($1, lower($2), $3, $4, $5)`,
|
||||
userID, email, remoteAddress, successful, reason)
|
||||
return err
|
||||
}
|
||||
416
internal/store/store.go
Normal file
416
internal/store/store.go
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/danyel/go-loose/internal/contract"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
type Tenant struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type Application struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
TenantSlug string `json:"tenant_slug,omitempty"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
AllowedHosts []string `json:"allowed_hosts"`
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
ClientReady bool `json:"client_ready"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
ID string `json:"id"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
Name string `json:"name"`
|
||||
Prefix string `json:"prefix"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Contract struct {
|
||||
ID string `json:"id"`
|
||||
ApplicationID string `json:"application_id"`
|
||||
Version string `json:"version"`
|
||||
SourceURL *string `json:"source_url,omitempty"`
|
||||
EndpointCount int `json:"endpoint_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Authorization struct {
|
||||
Allowed bool `json:"allowed"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
TenantSlug string `json:"tenant_slug,omitempty"`
|
||||
AppID string `json:"application_id,omitempty"`
|
||||
AppSlug string `json:"application_slug,omitempty"`
|
||||
APIKeyID string `json:"api_key_id,omitempty"`
|
||||
APIKeyName string `json:"api_key_name,omitempty"`
|
||||
}
|
||||
|
||||
func New(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) UpsertUser(ctx context.Context, subject, email, displayName string) (User, error) {
|
||||
var user User
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
UPDATE users SET subject = $1, email = lower($2), display_name = $3, last_login_at = now()
|
||||
WHERE lower(email) = lower($2)
|
||||
RETURNING id, email, display_name`, subject, email, displayName).
|
||||
Scan(&user.ID, &user.Email, &user.DisplayName)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO users(subject, email, display_name)
|
||||
VALUES ($1, lower($2), $3)
|
||||
RETURNING id, email, display_name`, subject, email, displayName).
|
||||
Scan(&user.ID, &user.Email, &user.DisplayName)
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) Bootstrap(ctx context.Context, userID, tenantSlug, appSlug string) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var tenantID string
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO tenants(slug, name) VALUES ($1, $2)
|
||||
ON CONFLICT (slug) DO UPDATE SET name = tenants.name
|
||||
RETURNING id`, tenantSlug, title(tenantSlug)).Scan(&tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO memberships(tenant_id, user_id, role)
|
||||
SELECT $1, $2, 'owner'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1)
|
||||
OR EXISTS (SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $2)
|
||||
ON CONFLICT DO NOTHING`, tenantID, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO applications(tenant_id, slug, name, description)
|
||||
VALUES ($1, $2, $3, 'Bootstrap application')
|
||||
ON CONFLICT (tenant_id, slug) DO NOTHING`, tenantID, appSlug, title(appSlug)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO user_application_access(application_id, user_id, granted_by)
|
||||
SELECT a.id, $2, $2
|
||||
FROM applications a
|
||||
JOIN memberships m ON m.tenant_id = a.tenant_id AND m.user_id = $2
|
||||
WHERE a.tenant_id = $1 AND a.slug = $3
|
||||
ON CONFLICT DO NOTHING`, tenantID, userID, appSlug); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) ListTenants(ctx context.Context, userID string) ([]Tenant, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT t.id, t.slug, t.name, m.role
|
||||
FROM tenants t
|
||||
JOIN memberships m ON m.tenant_id = t.id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY t.name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Tenant, 0)
|
||||
for rows.Next() {
|
||||
var item Tenant
|
||||
if err := rows.Scan(&item.ID, &item.Slug, &item.Name, &item.Role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateTenant(ctx context.Context, userID, slug, name string) (Tenant, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var tenant Tenant
|
||||
err = tx.QueryRowContext(ctx, `INSERT INTO tenants(slug, name) VALUES ($1, $2) RETURNING id, slug, name`, slug, name).
|
||||
Scan(&tenant.ID, &tenant.Slug, &tenant.Name)
|
||||
if err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(tenant_id, user_id, role) VALUES ($1, $2, 'owner')`, tenant.ID, userID); err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
tenant.Role = "owner"
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Tenant{}, err
|
||||
}
|
||||
return tenant, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListApplications(ctx context.Context, userID string) ([]Application, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT a.id, a.tenant_id, t.slug, a.slug, a.name, a.description,
|
||||
to_json(a.allowed_hosts), a.client_id, to_json(a.redirect_uris),
|
||||
(a.client_secret_hash IS NOT NULL)
|
||||
FROM applications a
|
||||
JOIN tenants t ON t.id = a.tenant_id
|
||||
JOIN memberships m ON m.tenant_id = a.tenant_id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY t.slug, a.name`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Application, 0)
|
||||
for rows.Next() {
|
||||
var item Application
|
||||
var allowedHosts, redirectURIs []byte
|
||||
if err := rows.Scan(&item.ID, &item.TenantID, &item.TenantSlug, &item.Slug, &item.Name, &item.Description, &allowedHosts, &item.ClientID, &redirectURIs, &item.ClientReady); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(allowedHosts, &item.AllowedHosts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(redirectURIs, &item.RedirectURIs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateApplication(ctx context.Context, userID string, app Application) (Application, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO applications(tenant_id, slug, name, description, allowed_hosts)
|
||||
SELECT $1, $2, $3, $4, $5
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM memberships WHERE tenant_id = $1 AND user_id = $6 AND role IN ('owner', 'admin')
|
||||
)
|
||||
RETURNING id, tenant_id, slug, name, description, to_json(allowed_hosts),
|
||||
client_id, to_json(redirect_uris), false`,
|
||||
app.TenantID, app.Slug, app.Name, app.Description, app.AllowedHosts, userID)
|
||||
var allowedHosts, redirectURIs []byte
|
||||
err := row.Scan(&app.ID, &app.TenantID, &app.Slug, &app.Name, &app.Description, &allowedHosts, &app.ClientID, &redirectURIs, &app.ClientReady)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Application{}, ErrNotFound
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(allowedHosts, &app.AllowedHosts)
|
||||
}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(redirectURIs, &app.RedirectURIs)
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (s *Store) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT k.id, k.application_id, k.name, k.prefix, k.status, k.expires_at, k.last_used_at, k.created_at
|
||||
FROM api_keys k
|
||||
JOIN memberships m ON m.tenant_id = k.tenant_id
|
||||
WHERE m.user_id = $1
|
||||
ORDER BY k.created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]APIKey, 0)
|
||||
for rows.Next() {
|
||||
var item APIKey
|
||||
if err := rows.Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) CreateAPIKey(ctx context.Context, userID, applicationID, name, prefix string, hash []byte, expiresAt *time.Time) (APIKey, error) {
|
||||
var item APIKey
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO api_keys(tenant_id, application_id, name, prefix, secret_hash, expires_at, created_by)
|
||||
SELECT a.tenant_id, a.id, $2, $3, $4, $5, $6
|
||||
FROM applications a
|
||||
JOIN memberships m ON m.tenant_id = a.tenant_id
|
||||
WHERE a.id = $1 AND m.user_id = $6 AND m.role IN ('owner', 'admin')
|
||||
RETURNING id, application_id, name, prefix, status, expires_at, last_used_at, created_at`,
|
||||
applicationID, name, prefix, hash, expiresAt, userID).
|
||||
Scan(&item.ID, &item.ApplicationID, &item.Name, &item.Prefix, &item.Status, &item.ExpiresAt, &item.LastUsedAt, &item.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return APIKey{}, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Store) RevokeAPIKey(ctx context.Context, userID, keyID string) error {
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
UPDATE api_keys k SET status = 'revoked', revoked_at = now()
|
||||
FROM memberships m
|
||||
WHERE k.id = $1 AND m.tenant_id = k.tenant_id AND m.user_id = $2 AND m.role IN ('owner', 'admin')`,
|
||||
keyID, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
count, _ := result.RowsAffected()
|
||||
if count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Authorize(ctx context.Context, hash []byte, tenantSlug, appSlug, method, path, host string) (Authorization, error) {
|
||||
var auth Authorization
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT t.id, t.slug, a.id, a.slug, k.id, k.name,
|
||||
(k.status = 'active' AND (k.expires_at IS NULL OR k.expires_at > now())
|
||||
AND ($2 = '' OR t.slug = $2) AND ($3 = '' OR a.slug = $3)
|
||||
AND (cardinality(a.allowed_hosts) = 0 OR $4 = ANY(a.allowed_hosts))) AS allowed
|
||||
FROM api_keys k
|
||||
JOIN applications a ON a.id = k.application_id
|
||||
JOIN tenants t ON t.id = k.tenant_id
|
||||
WHERE k.secret_hash = $1`,
|
||||
hash, tenantSlug, appSlug, host).
|
||||
Scan(&auth.TenantID, &auth.TenantSlug, &auth.AppID, &auth.AppSlug, &auth.APIKeyID, &auth.APIKeyName, &auth.Allowed)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
auth.Reason = "invalid_api_key"
|
||||
s.recordAuthorization(ctx, auth, method, path, host)
|
||||
return auth, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Authorization{}, err
|
||||
}
|
||||
if auth.Allowed {
|
||||
auth.Reason = "authorized"
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE api_keys SET last_used_at = now() WHERE id = $1`, auth.APIKeyID)
|
||||
} else {
|
||||
auth.Reason = "key_scope_mismatch_or_inactive"
|
||||
}
|
||||
s.recordAuthorization(ctx, auth, method, path, host)
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func (s *Store) recordAuthorization(ctx context.Context, auth Authorization, method, path, host string) {
|
||||
_, _ = s.db.ExecContext(ctx, `
|
||||
INSERT INTO authorization_events(tenant_id, application_id, api_key_id, method, path, host, allowed, reason)
|
||||
VALUES (NULLIF($1, '')::uuid, NULLIF($2, '')::uuid, NULLIF($3, '')::uuid, $4, $5, $6, $7, $8)`,
|
||||
auth.TenantID, auth.AppID, auth.APIKeyID, method, path, host, auth.Allowed, auth.Reason)
|
||||
}
|
||||
|
||||
func (s *Store) SaveContract(ctx context.Context, userID, applicationID string, document contract.Document, sourceURL *string) (Contract, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Contract{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var result Contract
|
||||
err = tx.QueryRowContext(ctx, `
|
||||
INSERT INTO openapi_contracts(application_id, version, source_url, document, created_by)
|
||||
SELECT a.id, $2, $3, $4, $5
|
||||
FROM applications a
|
||||
JOIN memberships m ON m.tenant_id = a.tenant_id
|
||||
WHERE a.id = $1 AND m.user_id = $5 AND m.role IN ('owner', 'admin')
|
||||
RETURNING id, application_id, version, source_url, created_at`,
|
||||
applicationID, document.Version, sourceURL, document.JSON, userID).
|
||||
Scan(&result.ID, &result.ApplicationID, &result.Version, &result.SourceURL, &result.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Contract{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Contract{}, err
|
||||
}
|
||||
for _, endpoint := range document.Endpoints {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO contract_endpoints(contract_id, method, path, operation_id) VALUES ($1, $2, $3, $4)`,
|
||||
result.ID, endpoint.Method, endpoint.Path, endpoint.OperationID); err != nil {
|
||||
return Contract{}, err
|
||||
}
|
||||
}
|
||||
result.EndpointCount = len(document.Endpoints)
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Contract{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListContracts(ctx context.Context, userID string) ([]Contract, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT c.id, c.application_id, c.version, c.source_url, count(e.id), c.created_at
|
||||
FROM openapi_contracts c
|
||||
JOIN applications a ON a.id = c.application_id
|
||||
JOIN memberships m ON m.tenant_id = a.tenant_id
|
||||
LEFT JOIN contract_endpoints e ON e.contract_id = c.id
|
||||
WHERE m.user_id = $1
|
||||
GROUP BY c.id
|
||||
ORDER BY c.created_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]Contract, 0)
|
||||
for rows.Next() {
|
||||
var item Contract
|
||||
if err := rows.Scan(&item.ID, &item.ApplicationID, &item.Version, &item.SourceURL, &item.EndpointCount, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func title(slug string) string {
|
||||
parts := strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' })
|
||||
for index := range parts {
|
||||
if parts[index] != "" {
|
||||
parts[index] = strings.ToUpper(parts[index][:1]) + parts[index][1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func Wrap(operation string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue