Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
156 lines
6.1 KiB
Go
156 lines
6.1 KiB
Go
// SFTP credential store. Pattern ported from internal/userstore.go
|
|
// (bcrypt-hashed secret, timing-safe "not found" handling) but deliberately
|
|
// kept separate from the `users` table: an SFTP credential is a narrow,
|
|
// independently revocable secret scoped to exactly one tenant's inbox
|
|
// folder, not a full login account. See internal/sftpserver for the server
|
|
// that authenticates against this store and internal/api/sftp_handlers.go
|
|
// for the admin CRUD API.
|
|
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const sftpBcryptCost = 12
|
|
|
|
// sftpDummyBcryptHash burns bcrypt time on "username not found" so that
|
|
// VerifySFTPLogin does not leak credential existence via timing, mirroring
|
|
// userstore.dummyBcryptHash.
|
|
const sftpDummyBcryptHash = "$2a$12$C6UzMDM.H6dfI/f/IKcEeO4TW/OZ/6PdTdSU0/eV1JCJXo.0DGvTa"
|
|
|
|
// ErrSFTPCredentialNotFound is returned when a credential lookup/revoke
|
|
// targets a username or ID that doesn't exist (or doesn't belong to the
|
|
// given tenant).
|
|
var ErrSFTPCredentialNotFound = errors.New("storage: sftp credential not found")
|
|
|
|
// SFTPCredential is a per-tenant SFTP login. The password is never stored or
|
|
// returned in plaintext after creation.
|
|
type SFTPCredential struct {
|
|
ID int64 `json:"id"`
|
|
TenantID int64 `json:"tenant_id"`
|
|
Username string `json:"username"`
|
|
Active bool `json:"active"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
|
}
|
|
|
|
func (s *Store) initSFTPCredentialsSchema(ctx context.Context) error {
|
|
_, err := s.db.Exec(ctx, `
|
|
CREATE TABLE IF NOT EXISTS sftp_credentials (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
tenant_id BIGINT NOT NULL,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
active BOOLEAN NOT NULL DEFAULT true,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
last_login_at TIMESTAMPTZ
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_sftp_credentials_tenant ON sftp_credentials(tenant_id);
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: create sftp_credentials table: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateSFTPCredential creates a new SFTP credential for a tenant. The
|
|
// caller-supplied plaintext password is bcrypt-hashed before storage and is
|
|
// never persisted or retrievable again — the caller (the admin API handler)
|
|
// must return it to the requester exactly once, in the create response.
|
|
func (s *Store) CreateSFTPCredential(ctx context.Context, tenantID int64, username, password string) (*SFTPCredential, error) {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), sftpBcryptCost)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: sftp bcrypt: %w", err)
|
|
}
|
|
var c SFTPCredential
|
|
err = s.db.QueryRow(ctx, `
|
|
INSERT INTO sftp_credentials (tenant_id, username, password_hash, active, created_at)
|
|
VALUES ($1, $2, $3, true, now())
|
|
RETURNING id, tenant_id, username, active, created_at, last_login_at
|
|
`, tenantID, username, string(hash),
|
|
).Scan(&c.ID, &c.TenantID, &c.Username, &c.Active, &c.CreatedAt, &c.LastLoginAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: create sftp credential: %w", err)
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
// VerifySFTPLogin checks a username/password pair against sftp_credentials
|
|
// and returns the credential (with tenant scope) on success. Inactive
|
|
// credentials and wrong passwords both fail; unknown usernames burn a dummy
|
|
// bcrypt compare to avoid a timing side-channel that would reveal which
|
|
// usernames exist.
|
|
func (s *Store) VerifySFTPLogin(ctx context.Context, username, password string) (*SFTPCredential, error) {
|
|
var c SFTPCredential
|
|
var hash string
|
|
err := s.db.QueryRow(ctx, `
|
|
SELECT id, tenant_id, username, password_hash, active, created_at, last_login_at
|
|
FROM sftp_credentials WHERE username = $1
|
|
`, username).Scan(&c.ID, &c.TenantID, &c.Username, &hash, &c.Active, &c.CreatedAt, &c.LastLoginAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
_ = bcrypt.CompareHashAndPassword([]byte(sftpDummyBcryptHash), []byte(password))
|
|
return nil, fmt.Errorf("storage: sftp login: credential not found")
|
|
}
|
|
return nil, fmt.Errorf("storage: sftp login: %w", err)
|
|
}
|
|
if !c.Active {
|
|
return nil, fmt.Errorf("storage: sftp login: credential revoked")
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
|
return nil, fmt.Errorf("storage: sftp login: wrong password")
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
// TouchSFTPLastLogin sets last_login_at = now() for a credential. Called
|
|
// after a successful SFTP authentication.
|
|
func (s *Store) TouchSFTPLastLogin(ctx context.Context, id int64) error {
|
|
_, err := s.db.Exec(ctx, `UPDATE sftp_credentials SET last_login_at = now() WHERE id = $1`, id)
|
|
return err
|
|
}
|
|
|
|
// ListSFTPCredentials returns all SFTP credentials belonging to a tenant,
|
|
// newest first. Password hashes are never included in the returned struct.
|
|
func (s *Store) ListSFTPCredentials(ctx context.Context, tenantID int64) ([]SFTPCredential, error) {
|
|
rows, err := s.db.Query(ctx, `
|
|
SELECT id, tenant_id, username, active, created_at, last_login_at
|
|
FROM sftp_credentials WHERE tenant_id = $1 ORDER BY created_at DESC
|
|
`, tenantID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list sftp credentials: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]SFTPCredential, 0)
|
|
for rows.Next() {
|
|
var c SFTPCredential
|
|
if err := rows.Scan(&c.ID, &c.TenantID, &c.Username, &c.Active, &c.CreatedAt, &c.LastLoginAt); err != nil {
|
|
return nil, fmt.Errorf("storage: scan sftp credential: %w", err)
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RevokeSFTPCredential deactivates (soft-deletes) a credential, scoped to the
|
|
// owning tenant. Using active=false rather than a hard delete keeps the
|
|
// row (and its audit trail via created_at/last_login_at) around for GoBD
|
|
// traceability.
|
|
func (s *Store) RevokeSFTPCredential(ctx context.Context, id, tenantID int64) error {
|
|
tag, err := s.db.Exec(ctx, `UPDATE sftp_credentials SET active = false WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: revoke sftp credential: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrSFTPCredentialNotFound
|
|
}
|
|
return nil
|
|
}
|