Files
archivdms/internal/storage/sftp_credentials.go
T
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

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
}