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.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user