feat(PROJ-28): Self-Service Onboarding — Signup, Verify, Password Reset, Invites

- internal/mailer: SMTP-Out via net/smtp (TLS + STARTTLS), HTML+Text-Templates
- internal/tokenstore: auth_tokens Tabelle, SHA-256-Hash, TTL, einmalig verwendbar
- userstore: CreateInactive(), Activate(), GetByEmail(), SetPassword()
- API: POST /signup, GET /verify, POST /forgot-password, POST /reset-password
- API: POST /admin/invite (domain_admin+), GET /auth/invite?token (check)
- Login-Seite: Links zu "Passwort vergessen" und "Registrieren"
- Frontend: /signup, /verify, /forgot-password, /reset-password Seiten
- server.fqdn nicht konfiguriert → Startup-Warnung, Self-Service deaktiviert
- LDAP-Nutzer: Passwort-Reset abgewiesen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-31 21:54:11 +02:00
parent 7930b85cde
commit 4583262ea4
13 changed files with 1232 additions and 0 deletions
+51
View File
@@ -144,6 +144,57 @@ func (s *Store) Create(req CreateUserRequest) (*User, error) {
return s.GetByID(id)
}
// CreateInactive inserts a new local user with active=false (pending email verification).
func (s *Store) CreateInactive(req CreateUserRequest) (*User, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
if err != nil {
return nil, fmt.Errorf("userstore: bcrypt: %w", err)
}
ctx := context.Background()
var id int64
err = s.pool.QueryRow(ctx,
`INSERT INTO users (username, email, password_hash, role, source, active, created_at, tenant_id)
VALUES ($1, $2, $3, $4, 'local', false, NOW(), $5)
RETURNING id`,
req.Username, req.Email, string(hash), req.Role, req.TenantID,
).Scan(&id)
if err != nil {
return nil, fmt.Errorf("userstore: create inactive: %w", err)
}
return s.GetByID(id)
}
// Activate sets active=true for a user (called after email verification).
func (s *Store) Activate(ctx context.Context, id int64) error {
tag, err := s.pool.Exec(ctx, `UPDATE users SET active=true WHERE id=$1`, id)
if err != nil {
return fmt.Errorf("userstore: activate: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("userstore: user %d not found", id)
}
return nil
}
// GetByEmail retrieves a user by email address.
func (s *Store) GetByEmail(ctx context.Context, email string) (*User, error) {
row := s.pool.QueryRow(ctx,
`SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE email = $1`, email,
)
return scanUser(row)
}
// SetPassword updates the password hash for a user (used by password reset).
func (s *Store) SetPassword(ctx context.Context, id int64, newPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost)
if err != nil {
return fmt.Errorf("userstore: bcrypt: %w", err)
}
_, err = s.pool.Exec(ctx, `UPDATE users SET password_hash=$1 WHERE id=$2`, string(hash), id)
return err
}
// GetByID retrieves a user by their numeric ID.
func (s *Store) GetByID(id int64) (*User, error) {
ctx := context.Background()